SLOPSHOPPER

secret-mask

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

newstatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-mask
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ secret-mask: 2件の秘密の値を伏せました
README

secret-mask

コマンドの結果や読んだファイルに混じった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

まず、.envは読ませない設定に

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のようなコードもそのまま渡します。

気をつけること

  • 伏せるのは、Claude(AI)に届く分です。画面の表示と、手元に保存される会話の記録には、元の出力が残ります
  • 自分で打った頼みごとは伏せません。キーを貼り付けて頼んだ場合は、そのまま届きます
  • 形の決まっていない秘密は見逃すことがあります。名前に秘密を表す言葉がない設定に入った値などです。安全網として使ってください
Source 2 files
hooks/register.ts 18 lines
1import 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}
18
hooks/secrets.ts 116 lines
1/** 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