SLOPSHOPPER

secret-shield

Keeps secrets out of Claude's context: API keys, tokens, private keys and passwords in tool output are replaced with [REDACTED:kind] before the model or the…

newguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-shield
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-shield │ ⏺ Read(src/auth.ts) │ secret-shield hid 1 Stripe key, 1 password │ ⎿ Read 6 lines │ in a URL from Bash output │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /secret-shield ⎿ secret-shield: secret-shield is on. ⎿ secret-shield: Hidden this session (4): ⎿ secret-shield: 2 Stripe keys ⎿ secret-shield: 2 passwords in URLs ⎿ secret-shield: Hidden all time (4): ⎿ secret-shield: 2 Stripe keys ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🔐 secret-shield

Claude never sees your keys. API keys, tokens, private keys and passwords in tool output are swapped for [REDACTED:kind] before they reach the model or your transcript file.

<img src="../../../assets/screens/secret-shield.svg" alt="secret-shield in a real Claude Code session" width="100%">

● Bash(cat build.log)
  ⎿  step 1: checkout ok
     step 2: push with [REDACTED:github-token] ok
     step 3: done
                                   ╭──────────────────────────────────────────────╮
                                   │ secret-shield                                │
                                   │ secret-shield hid 1 GitHub token from Bash   │
                                   │ output                                       │
                                   ╰──────────────────────────────────────────────╯

cat .env, env, aws sts get-session-token, a stack trace with a connection string, a log line with a bearer token: one tool call is all it takes for a live secret to land in the model's context, in your ~/.claude/projects transcript, and in whatever you paste next. secret-shield scrubs every tool result on its way in.

Features

  • Redacts before storing. The tool's result is rewritten in tool.call, so the model reads the redacted text and the transcript keeps no copy, display record included. A session.append hook is the second net, for file attachments, settings-hook context and anything else that enters the conversation.
  • Finds 20+ secret formats. AWS access keys, secret keys and STS session tokens · GitHub (ghp_, gho_, ghs_, github_pat_…) · Anthropic · OpenAI · Slack tokens and webhooks · Stripe live/test keys and whsec_ · Google API keys · JWTs · npm · PyPI · Hugging Face · SendGrid · Authorization: Bearer … headers · PEM private keys, even half-printed ones · passwords in URLs (postgres://app:•••@db, user and host kept).
  • Understands assignments. .env, shell export, YAML, TOML, JSON and JS/TS/Python lines like API_KEY=…, password: …, "client_secret": "…" or const apiKey = "…" keep the name and lose the value. Lookalikes are left alone: MAX_TOKENS=1024, TOKEN_URL=https://…, tokenizer = "bert-base", password = get_password(), ${DB_PASSWORD} and <your-api-key>.
  • Fails closed. If a row ever can't be scanned, its text is withheld rather than stored raw.
  • Keeps you informed without noise. One toast per kind of secret per session.
  • /secret-shield shows what was hidden this session and all time. /secret-shield test <text> dry-runs any text.
  • Optional file lockout. With blockSecretFiles on, Read, Edit and Write are refused on .env*, *.pem/*.key, id_rsa/id_ed25519, ~/.ssh/*, ~/.aws/credentials, .npmrc, .netrc, .git-credentials, ~/.kube/config and service-account JSON. .env.example and its friends stay readable.

Install

/plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install secret-shield@awesome-claude-mods

Requires Claude Code 2.1.287 or later.

Configuration

OptionDefaultWhat it does
blockSecretFilesfalseAlso refuse Read, Edit and Write on files that usually hold secrets.
redactPromptsfalseAlso redact secrets you paste into your own prompts. Off by default, because a key you paste is usually meant for Claude.

How it works

EventWhy
tool.call (every tool)await next(e), then deep-redact the tool's structured result and answer { result }, so core re-maps it for the model and records the redacted copy. Also the optional secret-file lockout.
session.append on tool-result, tool-message, attachment, hook-context, deliveryRedacts text blocks and tool_result content of every row stored from outside the conversation. Its .catch withholds a row's text rather than storing it unscanned.
command.run/secret-shield status and dry-run
$.storeAll-time counts by kind

The detectors (hooks/patterns.ts) are pure TypeScript with no $, so you can import them into your own mod.

Test it

claude plugin test mods/safety/secret-shield   # 96 tests

A live headless run (claude -p --plugin-dir …) had Claude cat and Read a log line holding a fake GitHub token. The model quoted it back as [REDACTED:github-token], and the session's .jsonl transcript held no copy of the token.

Limitations

  • Detection is pattern-based. A secret with no recognizable format and no telltale name, such as a bare 32-character hex string on its own line, gets through. High-entropy guessing is left out on purpose, because it redacts commit SHAs and hashes.
  • It protects what flows into Claude's context. A secret Claude already knows, because you pasted it with redactPrompts off, can still be written into files.
  • Your terminal may briefly show a tool's raw output before the redacted row replaces it. The model and the transcript file never get the raw form.
  • This build's claude plugin test kit can't stand in beneath session.append, so that hook's row logic is unit-tested as a pure function (hooks/rows.ts) and was checked end to end in a live session.
Source 3 files
hooks/register.ts 120 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { addHits, countOf, isSecretFile, label, redact, redactDeep, type Hits } from './patterns'
4import { shieldRow, withheldRow } from './rows'
5
6// This session's tally; a reload starts it over, the all-time totals are in $.store.
7const session: Hits = {}
8const toasted = new Set<string>()
9
10async function record($: EngineInterface, hits: Hits, source: string) {
11  addHits(session, hits)
12  const fresh = Object.keys(hits).filter(kind => !toasted.has(kind))
13  if (fresh.length > 0) {
14    for (const kind of fresh) toasted.add(kind)
15    const what = Object.entries(hits).map(([kind, n]) => label(kind, n)).join(', ')
16    $.ui.toast(`secret-shield hid ${what} from ${source}`)
17  }
18  const totals = ((await $.store.get('totals')) ?? {}) as Hits
19  await $.store.set('totals', addHits({ ...totals }, hits))
20}
21
22function sourceOf(origin: { kind: string; tool?: unknown }): string {
23  if (origin.kind === 'tool' && typeof origin.tool === 'string') return `${origin.tool} output`
24  if (origin.kind === 'hook') return 'a hook'
25  return 'the conversation'
26}
27
28function report(hits: Hits): string {
29  const lines = Object.entries(hits)
30    .sort((a, b) => b[1] - a[1])
31    .map(([kind, n]) => `  ${label(kind, n)}`)
32  return lines.length === 0 ? '  nothing yet' : lines.join('\n')
33}
34
35export const register: Register = (on, options) => {
36  const blockFiles = options.blockSecretFiles === true
37  const scanPrompts = options.redactPrompts === true
38
39  on('session.start', async ($, e, next) => {
40    await $.command.register({
41      name: 'secret-shield',
42      description: 'What secret-shield has hidden from Claude, or dry-run it: /secret-shield test <text>',
43      argumentHint: '[test <text>]',
44    })
45    return next(e)
46  })
47
48  on('session.append', async ($, e, next) => {
49    const shielded = shieldRow(e, scanPrompts)
50    if (shielded === undefined) return next(e)
51
52    const stored = await next({ ...e, message: { ...e.message, content: shielded.content } })
53    try {
54      await record($, shielded.hits, sourceOf(e.origin))
55    } catch {
56      // The tally is a nicety; the row is already stored redacted.
57    }
58    return stored
59  }).catch(($, e, next) => {
60    // Never store what could not be scanned: the row keeps its shape, not its text.
61    const content = next.called ? undefined : withheldRow(e, scanPrompts)
62    return content === undefined ? next(e) : next({ ...e, message: { ...e.message, content } })
63  })
64
65  on('tool.call', async ($, e, next) => {
66    if (blockFiles) {
67      const path =
68        e.tool === 'Read' || e.tool === 'Edit' || e.tool === 'Write' ? e.file_path
69        : e.tool === 'NotebookEdit' ? e.notebook_path
70        : undefined
71      if (path !== undefined && isSecretFile(path)) {
72        $.ui.toast(`secret-shield kept Claude out of ${path.split('/').pop()}`)
73        return {
74          deny:
75            `secret-shield: ${path} holds secrets, and this session keeps Claude out of secret files. ` +
76            'Ask the user for the specific non-secret value you need, or use the .env.example file instead.',
77        }
78      }
79    }
80
81    // Redact the tool's own record too: answered with a new `result`, core
82    // maps it for the model and stores it as the call's result, so neither
83    // the model nor the transcript's display copy keeps the secret.
84    const ran = await next(e)
85    if (ran.deny !== undefined || ran.isError === true) return ran
86    const { value, hits } = redactDeep(ran.result)
87    if (countOf(hits) === 0) return ran
88    try {
89      await record($, hits, `${e.tool} output`)
90    } catch {
91      // The tally is a nicety.
92    }
93    return ran.context === undefined ? { result: value as typeof ran.result } : { result: value as typeof ran.result, context: ran.context }
94  })
95
96  on('command.run', { command: 'secret-shield' }, async ($, e) => {
97    const args = e.args.trim()
98    if (args.startsWith('test')) {
99      const sample = args.slice(4).trim()
100      if (sample === '') return { text: 'Usage: /secret-shield test <text to scan>' }
101      const { text, hits } = redact(sample)
102      return {
103        text: countOf(hits) === 0
104          ? 'secret-shield: nothing to hide in that text.'
105          : `secret-shield would hide ${Object.entries(hits).map(([k, n]) => label(k, n)).join(', ')}:\n${text}`,
106      }
107    }
108    const totals = ((await $.store.get('totals')) ?? {}) as Hits
109    return {
110      text: [
111        `secret-shield is on${blockFiles ? ', and blocks secret files' : ''}${scanPrompts ? ', scanning prompts too' : ''}.`,
112        `Hidden this session (${countOf(session)}):`,
113        report(session),
114        `Hidden all time (${countOf(totals)}):`,
115        report(totals),
116      ].join('\n'),
117    }
118  })
119}
120
hooks/patterns.ts 249 lines
1// Pure secret detection and redaction: no `$`, so tests and other mods can import it.
2
3export type Hits = Record<string, number>
4
5type Rule = {
6  kind: string
7  pattern: RegExp
8  /** Which capture group is the secret; 0 (the default) is the whole match. */
9  group?: number
10}
11
12/** What a redacted secret is replaced with. */
13export const mark = (kind: string): string => `[REDACTED:${kind}]`
14
15const MARK = /\[REDACTED:[a-z0-9-]+\]/
16
17// Specific, high-confidence token formats. Order matters: the more specific
18// prefix (sk-ant-) runs before the more general one (sk-).
19const TOKENS: Rule[] = [
20  { kind: 'private-key', pattern: /-----BEGIN ((?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?)-----[\s\S]*?(?:-----END \1-----|$)/g },
21  { kind: 'aws-access-key', pattern: /\b((?:AKIA|ASIA)[A-Z0-9]{16})\b/g, group: 1 },
22  { kind: 'aws-secret-key', pattern: /\b(?:aws_?secret_?(?:access_?)?key|secret_?access_?key)["']?\s*[=:]\s*["']?([A-Za-z0-9/+=]{40})(?![A-Za-z0-9/+=])/gi, group: 1 },
23  { kind: 'aws-secret-key', pattern: /"SecretAccessKey"\s*:\s*"([A-Za-z0-9/+=]{40})"/g, group: 1 },
24  { kind: 'aws-session-token', pattern: /"SessionToken"\s*:\s*"([A-Za-z0-9/+=]{100,})"/g, group: 1 },
25  { kind: 'github-token', pattern: /\b(gh[pousr]_[A-Za-z0-9]{36,255})\b/g, group: 1 },
26  { kind: 'github-token', pattern: /\b(github_pat_[A-Za-z0-9_]{22,255})\b/g, group: 1 },
27  { kind: 'anthropic-key', pattern: /\b(sk-ant-[A-Za-z0-9_-]{20,})/g, group: 1 },
28  { kind: 'openai-key', pattern: /\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,})/g, group: 1 },
29  { kind: 'openai-key', pattern: /\b(sk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20})\b/g, group: 1 },
30  { kind: 'openai-key', pattern: /\b(sk-[A-Za-z0-9]{48})\b/g, group: 1 },
31  { kind: 'slack-token', pattern: /\b(xox[abposr]-[A-Za-z0-9-]{10,})/g, group: 1 },
32  { kind: 'slack-webhook', pattern: /(https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+)/g, group: 1 },
33  { kind: 'stripe-key', pattern: /\b((?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,})\b/g, group: 1 },
34  { kind: 'stripe-key', pattern: /\b(whsec_[A-Za-z0-9]{24,})\b/g, group: 1 },
35  { kind: 'google-api-key', pattern: /\b(AIza[0-9A-Za-z_-]{35})(?![0-9A-Za-z_-])/g, group: 1 },
36  { kind: 'jwt', pattern: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, group: 1 },
37  { kind: 'npm-token', pattern: /\b(npm_[A-Za-z0-9]{36})\b/g, group: 1 },
38  { kind: 'pypi-token', pattern: /\b(pypi-AgE[A-Za-z0-9_-]{50,})/g, group: 1 },
39  { kind: 'huggingface-token', pattern: /\b(hf_[A-Za-z0-9]{34,})\b/g, group: 1 },
40  { kind: 'sendgrid-key', pattern: /\b(SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/g, group: 1 },
41  { kind: 'bearer-token', pattern: /\b(?:Authorization|Proxy-Authorization)["']?\s*[:=]\s*["']?(?:Bearer|Basic|token|Token)\s+([A-Za-z0-9._~+/=-]{16,})/g, group: 1 },
42]
43
44// A value that is obviously not a real secret: a reference, a placeholder.
45const PLACEHOLDER = /^(\$\{?[A-Za-z_][A-Za-z0-9_]*\}?|\$\(.*\)|\{\{.*\}\}|<[^>]*>|\*+|x+|X+|\.{3}|…|changeme|change_me|password|passwd|secret|token|your[-_ a-z]*|example|dummy|test|none|null|nil|undefined|true|false|empty|redacted|process\.env\.\w+|os\.environ.*|env\(.*\))$/i
46
47// Names that say "this value is a secret", matched on the snake_case form
48// (`apiKey` reads `api_key`) so `tokenizer` and `max_tokens` do not count...
49const SECRET_NAME = /(^|[_.-])(secrets?|token|password|passwd|passphrase|pwd|pass|api_?key|apikey|private_?key|access_?key|auth_?key|auth_?token|client_?secret|credentials?|signing_?key|encryption_?key|session_?key|master_?key)([_.-]|$)/
50// ...unless they are about the secret rather than the secret itself.
51const NOT_SECRET_NAME = /[_.-](url|uri|endpoint|path|file|dir|name|type|expiry|expires|expires_at|ttl|length|header|id|count|limit|size|prefix|env|var|mode|enabled|required|policy|hint|prompt|label|placeholder|field|format)$/
52
53function isSecretName(name: string): boolean {
54  const snake = name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
55  return SECRET_NAME.test(snake) && !NOT_SECRET_NAME.test(snake)
56}
57
58function looksSecret(value: string, isQuoted: boolean): boolean {
59  if (value === '' || MARK.test(value) || PLACEHOLDER.test(value)) return false
60  if (/^\d{1,7}$/.test(value)) return false
61  if (isQuoted) return value.length >= 3 && !/\s/.test(value)
62  // Unquoted: a code expression (`get_token()`, `self.token`) is not a secret.
63  if (/[\s()[\]{};,]/.test(value)) return false
64  if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$/.test(value)) return false
65  return (value.length >= 6 && /\d/.test(value)) || value.length >= 16
66}
67
68/** Replaces each match of `rule` with its mark, counting into `hits`. */
69function applyRule(text: string, rule: Rule, hits: Hits): string {
70  return text.replace(rule.pattern, (match: string, ...groups: unknown[]) => {
71    const group = rule.group ?? 0
72    const secret = group === 0 ? match : String(groups[group - 1] ?? '')
73    if (secret === '' || MARK.test(secret)) return match
74    hits[rule.kind] = (hits[rule.kind] ?? 0) + 1
75    return group === 0 ? mark(rule.kind) : match.replace(secret, mark(rule.kind))
76  })
77}
78
79/** `scheme://user:password@host`: keeps the user and host, hides the password. */
80function redactUrlPasswords(text: string, hits: Hits): string {
81  return text.replace(/\b([a-z][a-z0-9+.-]*:\/\/)([^\s:/@'"`]+):([^\s@'"`/]+)@/gi, (match, scheme: string, user: string, password: string) => {
82    if (MARK.test(password) || PLACEHOLDER.test(password)) return match
83    hits['url-password'] = (hits['url-password'] ?? 0) + 1
84    return `${scheme}${user}:${mark('url-password')}@`
85  })
86}
87
88/** `.env`, shell, YAML and TOML lines: `API_KEY=...`, `export TOKEN="..."`, `password: ...`. */
89function redactAssignments(text: string, hits: Hits): string {
90  const line = /^([ \t]*(?:export[ \t]+)?(?:(?:const|let|var|readonly|final|static|private|public)[ \t]+)*["']?)([A-Za-z_][A-Za-z0-9_.-]*)(["']?[ \t]*(?::[ \t]*[A-Za-z<>[\]|]+[ \t]*)?[=:][ \t]*)(["'`]?)([^\n\r]*?)\4([,;]?(?:[ \t]+(?:#|\/\/).*)?[ \t]*)$/gm
91  let out = text.replace(line, (match, lead: string, name: string, sep: string, quote: string, value: string, tail: string) => {
92    if (!isSecretName(name)) return match
93    // `x == y` is a comparison, not an assignment.
94    if (value.startsWith('=')) return match
95    if (!looksSecret(value, quote !== '')) return match
96    hits.assignment = (hits.assignment ?? 0) + 1
97    return `${lead}${name}${sep}${quote}${mark('assignment')}${quote}${tail}`
98  })
99  // JSON: `"api_key": "..."`.
100  out = out.replace(/"([A-Za-z0-9_.-]+)"(\s*:\s*)"((?:[^"\\]|\\.){3,})"/g, (match, name: string, sep: string, value: string) => {
101    if (!isSecretName(name) || !looksSecret(value, true)) return match
102    hits.assignment = (hits.assignment ?? 0) + 1
103    return `"${name}"${sep}"${mark('assignment')}"`
104  })
105  return out
106}
107
108/** Every secret in `text` replaced by its mark, and how many of each kind. */
109export function redact(text: string): { text: string; hits: Hits } {
110  const hits: Hits = {}
111  let out = text
112  for (const rule of TOKENS) out = applyRule(out, rule, hits)
113  out = redactUrlPasswords(out, hits)
114  out = redactAssignments(out, hits)
115  return { text: out, hits }
116}
117
118export function countOf(hits: Hits): number {
119  return Object.values(hits).reduce((sum, n) => sum + n, 0)
120}
121
122export function addHits(into: Hits, from: Hits): Hits {
123  for (const [kind, n] of Object.entries(from)) into[kind] = (into[kind] ?? 0) + n
124  return into
125}
126
127/**
128 * Redacts every string inside a plain JSON value (a tool's structured result),
129 * keeping its shape; the value itself comes back when nothing was hidden.
130 */
131export function redactDeep(value: unknown): { value: unknown; hits: Hits } {
132  const hits: Hits = {}
133  const walk = (node: unknown, depth: number): unknown => {
134    if (typeof node === 'string') {
135      const done = redact(node)
136      if (countOf(done.hits) === 0) return node
137      addHits(hits, done.hits)
138      return done.text
139    }
140    if (depth > 12 || node === null || typeof node !== 'object') return node
141    if (Array.isArray(node)) return node.map(item => walk(item, depth + 1))
142    const out: Record<string, unknown> = {}
143    for (const [key, item] of Object.entries(node)) out[key] = walk(item, depth + 1)
144    return out
145  }
146  const out = walk(value, 0)
147  return { value: countOf(hits) === 0 ? value : out, hits }
148}
149
150type Block = { type: string; [field: string]: unknown }
151
152/**
153 * Redacts the text a row's blocks carry: text blocks and each tool_result's
154 * content (a string or text blocks). Every other block is passed as it is.
155 */
156export function redactBlocks(content: readonly Block[]): { content: Block[]; hits: Hits } {
157  const hits: Hits = {}
158  const scrub = (text: string): string => {
159    const done = redact(text)
160    addHits(hits, done.hits)
161    return done.text
162  }
163  const blocks = content.map((block): Block => {
164    if (block.type === 'text' && typeof block.text === 'string') return { ...block, text: scrub(block.text) }
165    if (block.type === 'tool_result') {
166      const inner = block.content
167      if (typeof inner === 'string') return { ...block, content: scrub(inner) }
168      if (Array.isArray(inner)) {
169        return {
170          ...block,
171          content: inner.map((part: Block) => (part.type === 'text' && typeof part.text === 'string' ? { ...part, text: scrub(part.text) } : part)),
172        }
173      }
174    }
175    return block
176  })
177  return { content: blocks, hits }
178}
179
180/** The same blocks with every text they carry withheld: what a failed scan stores. */
181export function withhold(content: readonly Block[], note: string): Block[] {
182  return content.map((block): Block => {
183    if (block.type === 'text') return { ...block, text: note }
184    if (block.type === 'tool_result') return { ...block, content: note }
185    return block
186  })
187}
188
189const SAFE_ENV = /^\.env\.(example|sample|template|dist|defaults|schema)$/i
190const SECRET_FILES: RegExp[] = [
191  /^\.env(\..+)?$/i,
192  /^\.envrc$/,
193  /\.(pem|key|p12|pfx|jks|keystore|asc|gpg)$/i,
194  /^id_(rsa|dsa|ecdsa|ed25519)$/,
195  /^\.?netrc$|^_netrc$/,
196  /^\.npmrc$/, /^\.pypirc$/, /^\.git-credentials$/,
197  /^credentials(\.json)?$/i,
198  /^service[-_]?account.*\.json$/i,
199  /^secrets?\.(ya?ml|json|toml|env)$/i,
200  /^\.htpasswd$/,
201]
202
203/** Whether `path` names a file that usually holds secrets. */
204export function isSecretFile(path: string): boolean {
205  const parts = path.split(/[\\/]/)
206  const name = parts.at(-1) ?? ''
207  if (SAFE_ENV.test(name) || name.endsWith('.pub')) return false
208  const parent = parts.at(-2) ?? ''
209  if (parent === '.aws' && (name === 'credentials' || name === 'config')) return true
210  if (parent === '.docker' && name === 'config.json') return true
211  if (parent === '.kube' && name === 'config') return true
212  if (parent === '.ssh' && name !== 'known_hosts' && name !== 'config' && name !== 'authorized_keys') return true
213  return SECRET_FILES.some(pattern => pattern.test(name))
214}
215
216const LABELS: Record<string, string> = {
217  'private-key': 'private key',
218  'aws-access-key': 'AWS access key',
219  'aws-secret-key': 'AWS secret key',
220  'aws-session-token': 'AWS session token',
221  'github-token': 'GitHub token',
222  'anthropic-key': 'Anthropic API key',
223  'openai-key': 'OpenAI API key',
224  'slack-token': 'Slack token',
225  'slack-webhook': 'Slack webhook',
226  'stripe-key': 'Stripe key',
227  'google-api-key': 'Google API key',
228  jwt: 'JWT',
229  'npm-token': 'npm token',
230  'pypi-token': 'PyPI token',
231  'huggingface-token': 'Hugging Face token',
232  'sendgrid-key': 'SendGrid key',
233  'bearer-token': 'bearer token',
234  'url-password': 'password in a URL',
235  assignment: 'secret value',
236}
237
238const PLURALS: Record<string, string> = {
239  'url-password': 'passwords in URLs',
240  'bearer-token': 'bearer tokens',
241  jwt: 'JWTs',
242}
243
244/** A kind's name for people, with its count: `1 AWS access key`, `2 GitHub tokens`. */
245export function label(kind: string, n = 1): string {
246  const name = LABELS[kind] ?? kind
247  return n === 1 ? `1 ${name}` : `${n} ${PLURALS[kind] ?? `${name}s`}`
248}
249
hooks/rows.ts 29 lines
1// Which conversation rows secret-shield scans, and the row it stores instead: no `$`.
2
3import { countOf, redactBlocks, withhold, type Hits } from './patterns'
4
5type Block = { type: string; [field: string]: unknown }
6type Row = { door: string; message: { content: readonly Block[] } }
7
8// The doors that carry text from outside the conversation: tool output, the
9// rows a tool hands over, files the engine attaches, settings hooks' context.
10const SCANNED = new Set(['tool-result', 'tool-message', 'attachment', 'hook-context', 'delivery'])
11
12export const WITHHELD = '[secret-shield withheld this output: it could not be scanned for secrets]'
13
14export function isScanned(door: string, scanPrompts: boolean): boolean {
15  return SCANNED.has(door) || (scanPrompts && door === 'prompt')
16}
17
18/** The row's redacted content and what was hidden, or undefined when it keeps as it is. */
19export function shieldRow(row: Row, scanPrompts: boolean): { content: Block[]; hits: Hits } | undefined {
20  if (!isScanned(row.door, scanPrompts)) return undefined
21  const done = redactBlocks(row.message.content)
22  return countOf(done.hits) === 0 ? undefined : done
23}
24
25/** What a row that could not be scanned keeps: its blocks, not their text. */
26export function withheldRow(row: Row, scanPrompts: boolean): Block[] | undefined {
27  return isScanned(row.door, scanPrompts) ? withhold(row.message.content, WITHHELD) : undefined
28}
29