Masks API keys, tokens, passwords, private keys, and database URL passwords in tool output, prompts, and attached files before Claude reads them. /shield lists…

Claude Code mod:在 Claude 讀到之前,遮蔽 API key、token、密碼、私鑰和資料庫 URL 裡的密碼。在 Claude Code 2.1.288 上測過。
涵蓋的地方:
不連網、不讀寫檔案、不跑程式、不讀環境變數。只呼叫 $.ui.log、$.ui.toast、$.ui.open、$.command.register 和 $.state,顯示的是種類、變數名稱和行號,從不顯示值。
claude plugin marketplace add justinhsu1477/claude-secret-shield
claude plugin install secret-shield@claude-secret-shield
這個 repo 是 private,git clone 用的 GitHub 帳號要有讀取權限。
Claude 工作時,spinner 後面會出現 🛡。沒看到 🛡 就代表 mod 沒載入,沒有任何遮蔽。可以跑 /plugin 看 mods active 那一行。
/shield已遮蔽 1 個 env-secret(Read /app/.env)。/shield 開清單,列出這個 session 每個遮蔽過的地方:來源(檔案路徑、指令、MCP 工具)、行號、變數名稱、種類,不顯示值。清單只存在這個 session,關掉就沒了。.env.example 這類會被 commit 的範本、settings hook 或其他 plugin 加進來的內容.env、*.pem、*.key、credentials、.npmrc、.netrc、id_rsa、printenv / env 的輸出、Neon get_connection_string 這類本來就給密鑰的 MCP 工具、你自己 @ 的檔案grep -n / rg 的輸出看每一行開頭的檔名判斷/shield。[[redacted:env-secret]],種類名稱保留,值不保留。Bearer / Basicpostgres://user:密碼@host.env 的 API_KEY=...、export DB_PASSWORD="...",JSON / PHP / JS 的 "HashKey": "...",YAML / INI 的 password: ...靠規則比對,不是萬能:
mysql -p...、--password=、curl -u user:pass、-H "X-API-Key: ...")、.netrc、docker auth、kubeconfig client-key-data、Azure 連線字串、Cookie 還沒涵蓋PASSWORD= 這類名稱後面的密鑰認不出來curl -d @.env 這種不需要看到值的外傳擋不住,要靠 Claude Code 的 sandbox 和 permissions.denyqueue-operation 紀錄(~/.claude/projects/),mod 攔不到;送給模型的內容已經遮蔽~/.claude/file-history 裡的檔案快照不會被回頭遮蔽# 直接載入目錄,存檔即熱重載
claude --plugin-dir plugins/secret-shield
# 測試與檢查
cd plugins/secret-shield && claude plugin test && claude plugin validate --strict .
測試裡的假密鑰都是分段拼出來的,所以這個 repo 本身不會被當成含有密鑰。完整寫出的 HashKey / HashIV 是綠界官方文件公開的測試商店金鑰(MerchantID 2000132)。
改完要讓已安裝的版本生效:先把 plugins/secret-shield/.claude-plugin/plugin.json 的 version 加一,再跑 claude plugin marketplace update claude-secret-shield 和 claude plugin update secret-shield@claude-secret-shield。
/plugin 停用,或 claude plugin disable secret-shield@claude-secret-shieldclaude --safe-modehooks/register.js 224 lines1// secret-shield: masks secrets before Claude reads them, in tool results, in the
2// prompts you send, in what Claude Code attaches for Claude, such as an
3// @-mentioned file or CLAUDE.md, and in every row the conversation stores. It
4// sends nothing and keeps nothing past the session. It shows where it masked
5// what (kinds, names, lines, never values) in a transcript line and in /shield,
6// and opens /shield by itself the first time a place holds a secret it
7// shouldn't (⚠): source code, a log, a web page, CLAUDE.md.
8//
9// When a check fails, the content is withheld rather than passed on unchecked.
10// Showing where something was masked never decides that: its failure is ignored.
11
12import { atom, read, update } from 'claude-code'
13import { MARKER, maskText, maskValue, total, describe } from './redact.js'
14
15const WITHHELD = 'secret-shield could not check this output for secrets, so it was withheld. Tell the user; do not retry the same call.'
16const PANE = 'shield'
17const OPEN = { id: PANE, title: 'secret-shield', focus: true, closeOnEscape: true, rows: 14 }
18
19// What was masked this session, newest last: { where, warn, spots }
20const masked = atom({ plugin: 'secret-shield', key: 'masked' }, [])
21
22// Files whose job is to hold secrets, so one found there is expected. An example
23// or template copy is meant to be committed, so one found there gets ⚠.
24const SECRET_FILE = /(?:^|[/\\])(?:\.env(?!\.(?:example|sample|template|dist)$)(?:\.[\w-]+)?|\.dev\.vars|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.git-credentials|credentials(?:\.[\w-]+)?|secrets?(?:\.[\w-]+)?|id_(?:rsa|dsa|ecdsa|ed25519)|[\w.-]+\.(?:pem|key|p12|pfx|jks|keystore))$/i
25// MCP tools that hand out a secret by design, such as Neon's get_connection_string
26const SECRET_TOOL = /credential|connection_string|secret|token|password|api_?key/i
27
28// Whether a secret is expected where a tool call found it
29function expected(e, spot) {
30 if (spot.file) return SECRET_FILE.test(spot.file)
31 const path = e.file_path ?? e.notebook_path ?? e.path
32 if (path) return SECRET_FILE.test(path)
33 if (e.tool === 'Bash') return /^\s*(?:env|printenv)\b/.test(e.command) || e.command.split(/[\s;|&<>'"=]+/).some((word) => SECRET_FILE.test(word))
34 return e.tool.startsWith('mcp__') && SECRET_TOOL.test(e.tool)
35}
36
37const oneLine = (text) => {
38 const line = String(text).replace(/\s+/g, ' ').trim()
39 return line.length > 70 ? line.slice(0, 69) + '…' : line
40}
41
42// Where a tool's output came from, in one line; a long path keeps its end, the
43// file's name, and a command is masked as well
44function where(e) {
45 const path = e.file_path ?? e.notebook_path
46 if (path) return e.tool + ' ' + (path.length > 60 ? '…' + path.slice(-59) : path)
47 const detail = e.url ?? (typeof e.command === 'string' ? maskText(e.command, {}) : e.pattern !== undefined ? e.pattern + (e.path ? ' ' + e.path : '') : '')
48 return oneLine(e.tool + ' ' + detail)
49}
50
51// One hit as a line: 第 3 行 ECPAY_HASH_KEY env-secret
52const spotText = (spot) => [spot.file ? spot.file + ':' + spot.fileLine : '第 ' + spot.line + ' 行', spot.name, spot.kind].filter(Boolean).join(' ')
53
54// Put a place last in the session's list, once; true when it needs a look and hadn't yet
55async function remember($, place, spots, warn) {
56 const isNew = warn && !(await read($, masked)).some((one) => one.warn && one.where === place)
57 await update($, masked, (list) => [...list.filter((one) => one.where !== place), { where: place, warn, spots: spots.slice(0, 20) }].slice(-100))
58 return isNew
59}
60
61// A transcript line, the session's list, and the list opened the first time a place needs a look
62async function report($, hits, place, spots, warn) {
63 $.ui.log((warn ? '⚠ ' : '') + '已遮蔽 ' + describe(hits) + '(' + place + ')' + (warn ? ',/shield 看位置' : ''))
64 if (!(await remember($, place, spots, warn))) return
65 // Opened unasked, a narrow terminal waits to draw it, so say where to look. A
66 // toast is a small box under the mod's name: keep it short, the place is in the line above
67 const opened = await $.ui.open(OPEN)
68 if (!opened.isPlaced) $.ui.toast('⚠ 密鑰出現在不該有的地方,輸入 /shield 查看')
69}
70
71const quietly = (work) => work.catch(() => undefined)
72
73async function shieldToolCall($, e, next) {
74 // A mask written back into a file would replace the real secret
75 if (JSON.stringify(e).includes(MARKER)) {
76 return { deny: 'This call contains a secret-shield mask (' + MARKER + '...]]). Running it would overwrite the real secret with the mask. Leave the lines that hold secrets unchanged, or ask the user to edit them.' }
77 }
78 const out = await next(e)
79 const hits = {}
80 const spots = []
81
82 // A refusal or an error reaches Claude as text, so mask that text
83 if (out.deny !== undefined || out.isError) {
84 const text = maskText(out.deny ?? out.text ?? String(out.result ?? ''), hits, spots)
85 if (total(hits) === 0) return out
86 await quietly(report($, hits, where(e) + ' 的錯誤訊息', spots, spots.some((spot) => !expected(e, spot))))
87 return { deny: text }
88 }
89
90 const result = maskValue(out.result, hits, spots)
91 const context = out.context?.map((c) => maskText(c, hits, spots))
92 if (total(hits) === 0) return out
93 // A read from line 40 counts its lines from there
94 const startLine = e.tool === 'Read' ? out.result?.file?.startLine ?? 1 : 1
95 for (const spot of spots) spot.line += startLine - 1
96 await quietly(report($, hits, where(e), spots, spots.some((spot) => !expected(e, spot))))
97 // Without core's ref, Claude Code maps the masked record for Claude afresh
98 return context ? { result, context } : { result }
99}
100
101async function shieldPrompt($, e, next) {
102 const hits = {}
103 const spots = []
104 const text = maskText(e.text, hits, spots)
105 const context = e.context?.map((c) => maskText(c, hits, spots))
106 if (total(hits) === 0) return next(e)
107 $.ui.toast('secret-shield:你的訊息裡有 ' + describe(hits) + ',已遮蔽後才送出')
108 await quietly(remember($, '你的訊息', spots, false))
109 return next(context ? { ...e, text, context } : { ...e, text })
110}
111
112async function shieldAttachment($, e, next) {
113 const hits = {}
114 const spots = []
115 const text = maskText(e.text, hits, spots)
116 if (total(hits) === 0) return next(e)
117 // A file you @-mentioned is the engine's; a settings hook's or plugin's text has no business holding one
118 await quietly(report($, hits, '附加內容 ' + e.type, spots, e.origin?.kind !== 'engine'))
119 return next({ ...e, text })
120}
121
122async function shieldContext($, e, next) {
123 const hits = {}
124 const spots = []
125 const blocks = e.blocks.map((b) => ({ ...b, text: maskText(b.text, hits, spots) }))
126 if (total(hits) === 0) return next(e)
127 await quietly(report($, hits, '開場內容,例如 CLAUDE.md', spots, true))
128 // The rewritten CLAUDE.md text no longer matches the file list, so leave the list out
129 const { instructionFiles, ...rest } = e
130 return next({ ...rest, blocks })
131}
132
133// The last check: every row the conversation keeps, before it's stored and sent.
134// It catches what the hooks above don't see, such as a skill's text or a hook's
135// added context. Claude's own replies are left out, since Claude can't repeat a
136// secret it never received.
137const ROW_DOORS = ['prompt', 'command', 'tool-result', 'tool-message', 'delivery', 'attachment', 'hook-context', 'note', 'compaction', 'notice']
138
139async function shieldRow($, e, next) {
140 const hits = {}
141 const spots = []
142 const content = maskValue(e.message.content, hits, spots)
143 if (total(hits) === 0) return next(e)
144 await quietly(report($, hits, '對話紀錄 ' + e.door, spots, true))
145 return next({ ...e, message: { ...e.message, content } })
146}
147
148// A row's text replaced by a notice, keeping each tool result's block so the
149// conversation still pairs every tool call with a result
150const WITHHELD_ROW = '[secret-shield withheld this content because it could not be checked for secrets]'
151function withhold(content) {
152 return content.map((block) => {
153 if (block.type === 'text') return { ...block, text: WITHHELD_ROW }
154 if (block.type === 'tool_result') return { ...block, content: [{ type: 'text', text: WITHHELD_ROW }] }
155 return block
156 })
157}
158
159// The session's list, newest first, ⚠ places in amber; values are never in it
160async function drawList($, e) {
161 const { Box, Text } = $.ui.resolve(e)
162 const list = await read($, masked)
163 const lines = []
164 for (const one of [...list].reverse()) {
165 lines.push(h(Text, one.warn ? { color: '#fbbf24', bold: true } : {}, (one.warn ? '⚠ ' : ' ') + one.where))
166 for (const spot of one.spots.slice(0, 5)) lines.push(h(Text, one.warn ? {} : { dimColor: true }, ' ' + spotText(spot)))
167 if (one.spots.length > 5) lines.push(h(Text, { dimColor: true }, ' …還有 ' + (one.spots.length - 5) + ' 個'))
168 }
169 const warns = list.filter((one) => one.warn).length
170 return h(
171 Box,
172 { flexDirection: 'column' },
173 h(Text, { bold: true }, list.length ? '這個 session 遮蔽了 ' + list.length + ' 處' + (warns ? ',' + warns + ' 處要注意' : '') + '(不顯示值)' : '這個 session 還沒有遮蔽任何東西'),
174 h(Text, { dimColor: true }, '⚠ = 密鑰出現在不該有的地方:原始碼、log、網頁、MCP 回應、CLAUDE.md'),
175 ...lines.slice(0, 80),
176 h(Text, { dimColor: true }, 'Esc 關閉 · /shield 再打開'),
177 )
178}
179
180export function register(on) {
181 on('tool.call', shieldToolCall).catch(async ($, e, next) => {
182 return { deny: WITHHELD + ' (' + next.error.kind + ')' }
183 })
184
185 on('prompt.submit', shieldPrompt).catch(async ($, e, next) => {
186 // Once the prompt was passed on, it went in masked; otherwise keep it from going in
187 return next.called ? next(e) : { drop: 'secret-shield 無法檢查這則訊息,所以沒有送出。' }
188 })
189
190 on('prompt.attachment', shieldAttachment).catch(async () => {
191 return { text: null }
192 })
193
194 on('prompt.context', shieldContext).catch(async ($, e, next) => {
195 // Send the conversation without the blocks that couldn't be checked
196 return { blocks: e.blocks.filter((b) => b.name === 'currentDate') }
197 })
198
199 on('session.append', { door: ROW_DOORS }, shieldRow).catch(async ($, e, next) => {
200 // Once the row was passed on, it went in masked; otherwise store it withheld
201 return next.called ? next(e) : next({ ...e, message: { ...e.message, content: withhold(e.message.content) } })
202 })
203
204 on('session.start', async ($, e, next) => {
205 await $.command.register({ name: 'shield', description: '這個 session 遮蔽了什麼、在哪裡(不顯示值)' })
206 return next(e)
207 })
208
209 on('command.run', { command: 'shield' }, async ($) => {
210 const list = await read($, masked)
211 await $.ui.open(OPEN)
212 const warns = list.filter((one) => one.warn).length
213 return { text: list.length ? '這個 session 遮蔽了 ' + list.length + ' 處' + (warns ? ',' + warns + ' 處要注意(⚠)' : '') : '這個 session 還沒有遮蔽任何東西' }
214 })
215
216 on('ui.render', { component: 'Pane', requestId: PANE }, drawList)
217
218 // A 🛡 after the spinner's word while Claude works shows the shield is running.
219 // If it's missing, this mod didn't load and nothing is being masked.
220 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
221 return next({ ...e, props: { ...e.props, suffix: (e.props.suffix ?? '') + ' 🛡' } })
222 })
223}
224hooks/redact.js 141 lines1// Finds secrets in text and replaces each with a mask such as [[redacted:github-token]].
2// Pure functions with no mods API calls, so tests can run them directly.
3
4// Every mask starts with this, so a call that would write a mask back into a file
5// can be recognized. Built from two parts so this file never contains it whole,
6// which would make edits to this file look like a mask being written back.
7export const MARKER = '[[' + 'redacted:'
8const mask = (kind) => MARKER + kind + ']]'
9
10// Name parts that mark a value as a secret, such as API_KEY, hashKey, or clientSecret.
11// The lookahead stops at the end of the word, so "tokenizer" doesn't count.
12const NAME = '(?:secret[_-]?key|secret|token|passw(?:or)?d|pwd|api[_-]?key|apikey|hash[_-]?key|hash[_-]?iv|private[_-]?key|access[_-]?key|auth[_-]?key|credentials?)s?(?![a-z])'
13const ENV_NAME = '(?:SECRET_?KEY|SECRET|TOKEN|PASSW(?:OR)?D|PWD|API_?KEY|APIKEY|HASH_?KEY|HASH_?IV|PRIVATE_?KEY|ACCESS_?KEY|AUTH_?KEY|CREDENTIALS?)S?(?![A-Za-z])'
14
15// A value that says where a secret lives, rather than being the secret
16const REFERENCE = /^\$|\$\{|process\.env|os\.environ|getenv|\[\[redacted:/
17// A value that is clearly a placeholder, a type, or a setting rather than a secret
18const NOT_SECRET = /^(?:x+|\*+|\.+|<[^>]*>|your[-_ ].*|changeme|change[-_]me|example.*|dummy.*|placeholder.*|null|none|nil|undefined|true|false|string|number|required|optional|\d+|https?:\/\/.*)$/i
19
20const isSecret = (v) => !REFERENCE.test(v) && !NOT_SECRET.test(v)
21// Code such as settings.API_KEY or getKey() on the right of an assignment
22const isCode = (v) => /^[A-Za-z_]\w*(?:\.\w+)+$/.test(v) || /[()[\]{}]/.test(v)
23
24// Each rule's regex names the secret part (?<secret>); the text around it is kept.
25// Quantifiers on names are bounded so long runs of letters can't make a rule slow.
26const RULES = [
27 // A PEM private key, through its END line, or to the end of text that was cut off
28 { kind: 'private-key', re: /(?<secret>-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----|[\s\S]*$))/g },
29
30 // Tokens with a recognizable prefix
31 { kind: 'anthropic-key', re: /(?<secret>\bsk-ant-[A-Za-z0-9_-]{20,})/g },
32 { kind: 'openai-key', re: /(?<secret>\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,})/g },
33 { kind: 'stripe-key', re: /(?<secret>\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,})/g },
34 { kind: 'stripe-webhook-secret', re: /(?<secret>\bwhsec_[A-Za-z0-9+/=]{20,})/g },
35 { kind: 'github-token', re: /(?<secret>\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}))/g },
36 { kind: 'gitlab-token', re: /(?<secret>\bglpat-[A-Za-z0-9_-]{20,})/g },
37 { kind: 'slack-token', re: /(?<secret>\bxox[abposr]-[A-Za-z0-9-]{10,})/g },
38 { kind: 'slack-webhook', re: /(?<before>https:\/\/hooks\.slack\.com\/services\/)(?<secret>[A-Za-z0-9_/-]{20,})/g },
39 { kind: 'aws-access-key-id', re: /(?<secret>\b(?:AKIA|ASIA)[0-9A-Z]{16}\b)/g },
40 { kind: 'google-api-key', re: /(?<secret>\bAIza[0-9A-Za-z_-]{35})/g },
41 { kind: 'npm-token', re: /(?<secret>\bnpm_[A-Za-z0-9]{36})/g },
42 { kind: 'neon-api-key', re: /(?<secret>\bnapi_[A-Za-z0-9]{30,})/g },
43 { kind: 'notion-token', re: /(?<secret>\b(?:secret_|ntn_)[A-Za-z0-9]{40,})/g },
44 { kind: 'clickup-token', re: /(?<secret>\bpk_\d{3,}_[A-Z0-9]{20,})/g },
45 { kind: 'jwt', re: /(?<secret>\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})/g },
46 { kind: 'bearer-token', re: /(?<before>\b(?:Bearer|Basic)[ \t]+)(?<secret>[A-Za-z0-9._~+/=-]{16,})/g },
47
48 // The password in a URL such as postgres://user:REDACTED@host
49 { kind: 'url-password', re: /(?<before>\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:/?#@]{1,100}:)(?<secret>[^\s@/]{3,200})(?=@)/gi, check: isSecret },
50
51 // An environment-style line: API_KEY=..., export DB_PASSWORD="...", - POSTGRES_PASSWORD: ...
52 { kind: 'env-secret', re: new RegExp('(?<before>^[ \\t]*(?:export[ \\t]+|-[ \\t]*)?[A-Z0-9_]{0,40}' + ENV_NAME + '[A-Z0-9_]{0,40}[ \\t]*[=:][ \\t]*)(?<q>["\']?)(?<secret>[^\\s"\'#]{4,})\\k<q>', 'gm'), check: (v) => isSecret(v) && !isCode(v) },
53
54 // A quoted value given to a secret-looking name: "HashKey": "...", $apiKey = '...', token: `...`
55 { kind: 'secret-value', re: new RegExp('(?<before>[\\w$.-]{0,40}' + NAME + '[\\w.-]{0,40}["\'`]?[ \\t]*(?::=|=>|:|=)[ \\t]*)(?<q>["\'`])(?<secret>[^"\'`\\s]{6,})\\k<q>', 'gi'), check: isSecret },
56
57 // An unquoted YAML or INI value with letters and digits: password: hunter2abc
58 { kind: 'secret-value', re: new RegExp('(?<before>^[ \\t]*-?[ \\t]*[\\w.-]{0,40}' + NAME + '[\\w.-]{0,40}[ \\t]*[:=][ \\t]*)(?<secret>[^\\s"\'#,;{}()[\\]]{8,})(?=[ \\t]*$)', 'gim'), check: (v) => isSecret(v) && /\d/.test(v) && /[A-Za-z]/.test(v) },
59]
60
61// Mask every secret in a string, adding one to hits[kind] for each. Given an
62// array `found`, also add where each one was (see locate), never its value.
63export function maskText(text, hits, found) {
64 let out = text
65 // Masks hold no line breaks, so only a private key block, masked first, takes
66 // lines away: each fold says after which line and how many, so later hits
67 // still get their line in the text as given
68 const folds = []
69 for (const rule of RULES) {
70 let from = 0
71 let line = 1
72 const pass = []
73 out = out.replace(rule.re, (...args) => {
74 const groups = args[args.length - 1]
75 if (rule.check && !rule.check(groups.secret)) return args[0]
76 hits[rule.kind] = (hits[rule.kind] ?? 0) + 1
77 if (found) {
78 const offset = args[args.length - 3]
79 const src = args[args.length - 2]
80 line += breaks(src, from, offset)
81 from = offset
82 const shift = folds.reduce((n, fold) => n + (fold.line < line ? fold.lost : 0), 0)
83 found.push(locate(rule.kind, groups.before, src, offset, line + shift))
84 const lost = breaks(args[0], 0, args[0].length)
85 if (lost) pass.push({ line: line - pass.reduce((n, fold) => n + fold.lost, 0), lost })
86 }
87 const q = groups.q ?? ''
88 return (groups.before ?? '') + q + mask(rule.kind) + q
89 })
90 folds.push(...pass)
91 }
92 return out
93}
94
95function breaks(text, from, to) {
96 let n = 0
97 for (let i = text.indexOf('\n', from); i !== -1 && i < to; i = text.indexOf('\n', i + 1)) n++
98 return n
99}
100
101// Where a hit was: its kind and line, the name it was given on its line
102// (API_KEY=, "HashKey": , postgres://app:), and for grep-style output
103// (src/pay.ts:12:...) the file and line
104function locate(kind, before, text, offset, line) {
105 const spot = { kind, line }
106 const start = text.lastIndexOf('\n', offset - 1) + 1
107 const lead = text.slice(Math.max(start, offset - 300), offset) + (before ?? '')
108 const name = /([\w$.-]+(?::\/\/[\w.-]+)?)["'`]?\s*(?::=|=>|[:=])\s*["'`]?$/.exec(lead)?.[1]
109 if (name) spot.name = name.slice(-60)
110 const grep = /^([^\s:]*[./][^\s:]*):(\d+)[:-]/.exec(text.slice(start, start + 300))
111 if (grep) {
112 spot.file = grep[1]
113 spot.fileLine = Number(grep[2])
114 }
115 return spot
116}
117
118// Binary content, such as an image's bytes, sits in these fields and is left alone
119function isBinaryField(obj, key) {
120 return key === 'base64' || (key === 'data' && ('media_type' in obj || 'mimeType' in obj || obj.type === 'base64'))
121}
122
123// Mask every string inside a value, keeping its shape so the tool's schema still fits
124export function maskValue(value, hits, found) {
125 if (typeof value === 'string') return maskText(value, hits, found)
126 if (Array.isArray(value)) return value.map((item) => maskValue(item, hits, found))
127 if (value && typeof value === 'object') {
128 const out = {}
129 for (const [key, item] of Object.entries(value)) {
130 out[key] = typeof item === 'string' && isBinaryField(value, key) ? item : maskValue(item, hits, found)
131 }
132 return out
133 }
134 return value
135}
136
137export const total = (hits) => Object.values(hits).reduce((sum, n) => sum + n, 0)
138
139// A summary that names the kinds and counts, never the values
140export const describe = (hits) => Object.entries(hits).map(([kind, n]) => n + ' 個 ' + kind).join('、')
141types/index.d.ts 14 lines1// What /shield lists, held in $.state for the session: where, never the value
2
3// One hit: its kind and line, the name it was given, and for grep-style output the file and line it names
4export type Spot = { kind: string; line: number; name?: string; file?: string; fileLine?: number }
5
6// One place something was masked; warn when a secret has no business being there (⚠)
7export type Masked = { where: string; warn: boolean; spots: Spot[] }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'secret-shield': { masked: Masked[] }
12 }
13}
14