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…

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.
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.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)..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>./secret-shield shows what was hidden this session and all time. /secret-shield test <text> dry-runs any text.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./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install secret-shield@awesome-claude-mods
Requires Claude Code 2.1.287 or later.
| Option | Default | What it does |
|---|---|---|
blockSecretFiles | false | Also refuse Read, Edit and Write on files that usually hold secrets. |
redactPrompts | false | Also redact secrets you paste into your own prompts. Off by default, because a key you paste is usually meant for Claude. |
| Event | Why |
|---|---|
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, delivery | Redacts 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 |
$.store | All-time counts by kind |
The detectors (hooks/patterns.ts) are pure TypeScript with no $, so you can import them into your own mod.
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.
redactPrompts off, can still be written into files.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.hooks/register.ts 120 lines1import 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}
120hooks/patterns.ts 249 lines1// 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}
249hooks/rows.ts 29 lines1// 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