Masks API keys, AWS/GitHub/Slack tokens, PEM private keys and Bearer values in Read, Grep and Bash results before the model reads them. Needs function hooks…

Read / Grep / Bash のツール結果から、モデルが読む前に秘密情報を伏せ字にする mod。
隠すもの: OpenAI 形式のキー (sk-…)、AWS のアクセスキー ID (AKIA…)、GitHub トークン (ghp_… など)、Slack トークン (xox?-…)、PEM の秘密鍵ブロック、Authorization: Bearer の値。 それぞれ [REDACTED:openai] のように種別つきで置き換える。
tool.call を { tool: ['Read', 'Grep', 'Bash'] } で受け、await next(e) で下から返ってきた 結果を書き換えて返す。ツールごとに結果の形が違う (Read は result.file.content、 Bash は result.stdout、Grep は型なし) ので、構造体の中の文字列を全部たどって置換する。
重要: モデルが実際に読むのは result (構造化レコード) のほう。text だけ書き換えても 素通しになる (NOTES.md の Gotcha 1 を参照)。
置換が 1 件でも起きた呼び出しは PROOF-scrub.log に 1 行記録する。
不要。伏せ字のルールは hooks/register.ts の RULES 配列。
❯ ./register.ts hooks: tool.call{tool=Read|Grep|Bash} ❯ ./register.ts calls: $.fs.read, $.fs.write
Read / Grep / Bash の結果しか見ない。ネットワークもプロセスも触らない。書き込みは 自分のログ 1 ファイルだけ。失敗時は fail closed (伏せ字にできなければ結果を deny する)。
cd mods/secret-scrub
claude plugin validate .
claude plugin test .
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 が要る。フック非対応ビルドでは hooks/hooks.json の modules が無視され、何もしない。
[REDACTED:pem] に畳まれるので、行番号が飛ぶ。hooks/register.ts 76 lines1import type { Register } from 'claude-code'
2
3
4// Ordered: the PEM block must match before the line-level rules chew it up.
5const RULES: { kind: string; re: RegExp }[] = [
6 { kind: 'pem', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
7 { kind: 'bearer', re: /(Authorization:[ \t]*Bearer[ \t]+)[A-Za-z0-9._~+/=-]+/g },
8 { kind: 'openai', re: /sk-[A-Za-z0-9_-]{16,}/g },
9 { kind: 'aws', re: /AKIA[0-9A-Z]{16}/g },
10 { kind: 'github', re: /gh[pousr]_[A-Za-z0-9]{20,}/g },
11 { kind: 'slack', re: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
12]
13
14type Hits = Record<string, number>
15
16function scrubText(text: string, hits: Hits): string {
17 let out = text
18 for (const rule of RULES) {
19 // Only the bearer rule has a capture group. For the others the second
20 // argument replace() passes is the match OFFSET, a number -- writing it
21 // out produced "137[REDACTED:openai]" until this typeof guard was added.
22 out = out.replace(rule.re, (_m: string, keep: unknown) => {
23 hits[rule.kind] = (hits[rule.kind] ?? 0) + 1
24 return `${typeof keep === 'string' ? keep : ''}[REDACTED:${rule.kind}]`
25 })
26 }
27 return out
28}
29
30// A tool result is a record, not a string: Read keeps the file under
31// result.file.content, Bash under result.stdout, Grep's record is untyped.
32// Walking every string reaches all three without a per-tool branch.
33function scrubValue(value: unknown, hits: Hits): unknown {
34 if (typeof value === 'string') return scrubText(value, hits)
35 if (Array.isArray(value)) return value.map(item => scrubValue(item, hits))
36 if (value !== null && typeof value === 'object') {
37 const out: Record<string, unknown> = {}
38 for (const [key, item] of Object.entries(value)) out[key] = scrubValue(item, hits)
39 return out
40 }
41 return value
42}
43
44export const register: Register = on => {
45 on('tool.call', { tool: ['Read', 'Grep', 'Bash'] }, async ($, e, next) => {
46 const r = await next(e)
47 if (r === null || typeof r !== 'object') return r
48 if ('deny' in r && r.deny !== undefined) return r
49
50 // Two counters: the record and the model-facing text are separate copies
51 // of the same bytes, so one shared counter would double every kind.
52 const inResult: Hits = {}
53 const inText: Hits = {}
54 const result = scrubValue(r.result, inResult)
55 const text = typeof r.text === 'string' ? scrubText(r.text, inText) : r.text
56 const kinds = [...new Set([...Object.keys(inResult), ...Object.keys(inText)])]
57 if (kinds.length === 0) return r
58
59 const summary = kinds.map(k => `${k}=${inResult[k] ?? 0}/${inText[k] ?? 0}`).join(' ')
60 const before = await $.fs.read(`${$.plugin.root}/PROOF-scrub.log`).catch(() => '')
61 const line =
62 `${new Date().toISOString()} tool=${e.tool} ` +
63 `resultKeys=[${Object.keys(r).join(',')}] ` +
64 `hits(record/text) ${summary}\n`
65 await $.fs.write(`${$.plugin.root}/PROOF-scrub.log`, `${before}${line}`)
66
67 // ref names core's own messages and core would reuse them verbatim, so a
68 // scrubbed answer must not carry it: return result + text only.
69 return { result, text, context: r.context }
70 }).catch(($, e, next) => {
71 // Fail CLOSED. The generic template replays next(e) when it already ran,
72 // but here that replay is the unscrubbed result, which is the whole point.
73 return { deny: `secret-scrub: the scrubber failed (${next.error.kind}), refusing to hand ${e.tool} output over unscrubbed` }
74 })
75}
76