コマンドの結果や読んだファイルに混じったAPIキー・パスワードを、Claudeが読む前に伏せる

コマンドの結果や読んだファイルに混じったAPIキーやパスワードを、Claudeが読む前に[MASKED]に置き換えるmodです。たとえばenvの結果に混じったキーは、Claudeには次のように届きます。
OPENAI_API_KEY=sk-[MASKED]
DB_PASSWORD=[MASKED]
DATABASE_URL=postgres://app:REDACTED@localhost:5432/shop
MAX_TOKENS=4096
APIキーを守る基本は、.envをClaudeに読ませないことです。.claude/settings.jsonに次のように書くと、ClaudeはReadツールでもcatでも.envを読めなくなります。
{
"permissions": {
"deny": ["Read(./.env)", "Read(./.env.*)"]
}
}
secret-maskは、それでも混じるキーのための安全網です。たとえば、PATHを確かめるつもりでenvを実行させると、環境変数に入れたキーも一緒にClaudeに渡ります。
| 種類 | 例 |
|---|---|
| 形で分かるキー | Anthropic、OpenAI、GitHub、AWSのアクセスキーID、Slack、GoogleのAPIキー、Stripe、JWT |
| 名前が秘密を表す設定 | .envやexportのDB_PASSWORD=...、JSONやYAMLの"password": "..." |
| URLに入ったパスワード | postgres://user:...@host |
| 認証のヘッダー | Authorization: Bearer ... |
| 秘密鍵 | -----BEGIN ... PRIVATE KEY-----からENDまで |
キーは見分けがつくよう、sk-やghp_のような頭の部分だけ残します。伏せたときは、結果の最後に「値を読まずに使う方法を選んで」という一文を添えて、Claudeに伝える仕組みです。
PWD、max_tokens=4096のような数字、trueのような切り替え、${OPENAI_API_KEY}のような参照は伏せません。password: stringのような型や、process.env.API_KEYのようなコードもそのまま渡します。
hooks/register.ts 18 lines1import type { Register } from 'claude-code'
2
3import { maskContent } from './secrets'
4
5// Rows that carry what a tool or a file handed over: what Claude reads, not what you typed.
6const DOORS = new Set(['tool-result', 'tool-message', 'attachment'])
7
8export const register: Register = on => {
9 on('session.append', ($, e, next) => {
10 if (!DOORS.has(e.door)) return next(e)
11 const { content, count } = maskContent(e.message.content)
12 if (count === 0) return next(e)
13 $.ui.status(`${count}件の秘密の値を伏せました`)
14
15 return next({ ...e, message: { ...e.message, content } })
16 })
17}
18hooks/secrets.ts 116 lines1/** What a masked value reads as, to Claude. */
2export const MASK = '[MASKED]'
3
4/** The line added under a tool result that had something masked. */
5export const noteLine = (count: number) =>
6 `[secret-mask] ${count}件の秘密の値を伏せました。値が必要なときは、値を読まずに使う方法(環境変数のまま渡すなど)を選んでください。`
7
8/** A secret recognized by its shape: the match's first `keep` characters stay as a hint. */
9type Token = { pattern: RegExp; keep: number }
10
11// Each starts at a word boundary and is long enough that ordinary words never match.
12const TOKENS: Token[] = [
13 { pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g, keep: 7 }, // Anthropic
14 { pattern: /\bsk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}/g, keep: 3 }, // OpenAI
15 { pattern: /\bgh[pousr]_[A-Za-z0-9]{30,}/g, keep: 4 }, // GitHub
16 { pattern: /\bgithub_pat_[A-Za-z0-9_]{30,}/g, keep: 11 }, // GitHub fine-grained
17 { pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, keep: 4 }, // AWS access key ID
18 { pattern: /\bxox[abeprs]-[A-Za-z0-9-]{10,}/g, keep: 5 }, // Slack
19 { pattern: /\bAIza[0-9A-Za-z_-]{35}/g, keep: 4 }, // Google API key
20 { pattern: /\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{16,}/g, keep: 8 }, // Stripe
21 { pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, keep: 3 }, // JWT
22]
23
24const PRIVATE_KEY = /-----BEGIN ([A-Z ]*)PRIVATE KEY-----[\s\S]*?-----END \1PRIVATE KEY-----/g
25const URL_PASSWORD = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)([^\s@/]+)(@)/gi
26const BEARER = /\b(Bearer|Basic)(\s+)([A-Za-z0-9._~+/=-]{16,})/g
27
28// A name that says it holds a secret, in an environment variable's capitals...
29const UPPER_WORD = '(?:SECRETS?|TOKENS?|PASSW(?:OR)?DS?|PWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?)'
30// ...or in a config key or variable of any case: `apiKey`, `db_password`, not `tokenizer`.
31const ANY_WORD =
32 '(?:[Ss]ecrets?|SECRETS?|[Tt]okens?|TOKENS?|[Pp]assw(?:or)?ds?|PASSW(?:OR)?DS?|[Pp]wd|PWD|' +
33 '[Aa]pi[_-]?[Kk]ey|API[_-]?KEY|[Aa]ccess[_-]?[Kk]ey|ACCESS[_-]?KEY|[Pp]rivate[_-]?[Kk]ey|PRIVATE[_-]?KEY|' +
34 '[Cc]redentials?|CREDENTIALS?)(?![a-z])'
35// `DB_PASSWORD=...` at the start of a line: a .env file, `env`, `export`, a compose list.
36// The start may carry the line number the Read tool prints.
37const ENV_SETTING = new RegExp(
38 `^([ \\t]*(?:\\d+\\t)?[ \\t]*(?:export[ \\t]+|-[ \\t]+)?)([A-Z0-9_.]*${UPPER_WORD}[A-Z0-9_.]*)([ \\t]*[:=][ \\t]*)` +
39 `(?:"([^"\\n]*)"|'([^'\\n]*)'|([^\\s"'#]+))`,
40 'gm',
41)
42// `"password": "..."`, `api_key = '...'`: a quoted value under a secret's name.
43const QUOTED_SETTING = new RegExp(
44 `(["']?)([A-Za-z0-9_.-]*${ANY_WORD}[A-Za-z0-9_.-]*)\\1(\\s*[:=]\\s*)(["'])([^"'\\n]+)\\4`,
45 'g',
46)
47// Names that say "secret" but hold none: the shell's working directories.
48const NOT_SECRET_NAMES = new Set(['PWD', 'OLDPWD'])
49// Values that are no secret: numbers, switches, references and placeholders.
50const NOT_SECRET_VALUE =
51 /^(?:\d+|true|false|null|none|yes|no|on|off|x+|\*+|changeme|your[-_].*|example.*|dummy.*|placeholder.*)$|^[$<%{]|\[MASKED\]/i
52
53const isSecretValue = (value: string) => value.length >= 4 && !NOT_SECRET_VALUE.test(value)
54
55/** `text` with every secret masked, and how many were. */
56export function mask(text: string): { text: string; count: number } {
57 let count = 0
58 const hide = (shown: string) => {
59 count += 1
60
61 return `${shown}${MASK}`
62 }
63 let out = text.replace(PRIVATE_KEY, (_, kind: string) => hide(`-----BEGIN ${kind}PRIVATE KEY-----`))
64 for (const { pattern, keep } of TOKENS) out = out.replace(pattern, match => hide(match.slice(0, keep)))
65 out = out.replace(URL_PASSWORD, (_, head: string, _password: string, at: string) => `${hide(head)}${at}`)
66 out = out.replace(BEARER, (_, scheme: string, gap: string) => hide(`${scheme}${gap}`))
67 out = out.replace(ENV_SETTING, (match, lead: string, name: string, sep: string, ...values: unknown[]) => {
68 const [double, single, bare] = values as (string | undefined)[]
69 const value = double ?? single ?? bare ?? ''
70 if (NOT_SECRET_NAMES.has(name) || !isSecretValue(value)) return match
71 const quote = double !== undefined ? '"' : single !== undefined ? "'" : ''
72
73 return `${lead}${name}${sep}${quote}${hide('')}${quote}`
74 })
75 out = out.replace(QUOTED_SETTING, (match, q1: string, name: string, sep: string, q2: string, value: string) =>
76 isSecretValue(value) ? `${q1}${name}${q1}${sep}${q2}${hide('')}${q2}` : match,
77 )
78
79 return { text: out, count }
80}
81
82type Block = { type: string; [field: string]: unknown }
83
84function withNote(text: string, count: number): string {
85 return count === 0 ? text : `${text}\n${noteLine(count)}`
86}
87
88/** One content block with its text masked: a text block, or a tool result's text. */
89export function maskBlock(block: Block): { block: Block; count: number } {
90 if (block.type === 'text' && typeof block.text === 'string') {
91 const masked = mask(block.text)
92
93 return { block: { ...block, text: masked.text }, count: masked.count }
94 }
95 if (block.type !== 'tool_result') return { block, count: 0 }
96 if (typeof block.content === 'string') {
97 const masked = mask(block.content)
98
99 return { block: { ...block, content: withNote(masked.text, masked.count) }, count: masked.count }
100 }
101 if (!Array.isArray(block.content)) return { block, count: 0 }
102 const inner = block.content.map(each => maskBlock(each as Block))
103 const count = inner.reduce((sum, each) => sum + each.count, 0)
104 const content = inner.map(each => each.block)
105 if (count > 0) content.push({ type: 'text', text: noteLine(count) })
106
107 return { block: { ...block, content }, count }
108}
109
110/** A row's blocks with every secret masked, and how many were. */
111export function maskContent(content: readonly Block[]): { content: Block[]; count: number } {
112 const masked = content.map(maskBlock)
113
114 return { content: masked.map(each => each.block), count: masked.reduce((sum, each) => sum + each.count, 0) }
115}
116