PII and secrets are swapped for placeholders (__PII_PERSON_1__) by regex and a local Gemma 4 before anything reaches Claude; placeholders are restored only…

Claude Code の Mods(function hooks)で、PII や秘密情報が Claude に届く前に伏せ字(__PII_PERSON_1__)へ置き換え、 手元で完結する処理と画面表示のときだけ元の値に戻す PoC。検出は正規表現とローカルの Gemma 4(LM Studio)で行う。
Mods は early access の API で、Claude Code のリリースごとに変わりうる。この PoC は Claude Code 2.1.289 で検証した。

docs/architecture.drawio.png は図のデータを埋め込んだ PNG で、draw.io でそのまま開いて編集できる。
.catch を付けて fail-closed にしている。付けないとエンジンが next(e) を代行し、原文が届く$.state に置き、hot reload 後も同じ番号で戻せる守るもの: Claude(Anthropic の API)に PII と秘密情報の原文が届かないこと。入力・ツール結果・添付・CLAUDE.md・ システムプロンプト・ツールの説明を、送る前に伏せ字にする。検出に失敗したら原文を送らずに差し止める(fail-closed)。
守らないもの:
bashRestore: off)では伏せ字を含む Bash を拒否し、元の値をシェルに渡さない。 with-approval を選ぶと、毎回の承認を経て戻し、通信系に見えるコマンド(curl、/usr/bin/curl、gh、git push など)には 承認に関わらず戻さない。ただし承認を下すのはモードごとの判定者で、オートモードでは判定器(Claude)が許可しうるし、 名前の判定はクォート(c''url)・変数展開(curl${IFS})・python3 -c などで抜ける。どちらの設定でも、 curl -d @patients.md ... のように元のファイルを直接送るコマンドには伏せ字が無く、この mod は止めない。 Claude Code の権限設定やサンドボックスで防ぐこと{"event":"unmasked-block","type":...} を出すjust install # TypeScript を入れる
just gemma # LM Studio のサーバを起動し、Gemma 4 12B(MLX 6bit)を読み込む
just run # この mod を読み込んだ Claude Code を起動する(claude --plugin-dir .)
just check # 型検査・claude plugin validate・claude plugin test
設定は ~/.claude/settings.json の pluginConfigs["privacy-gateway@inline"].options(または --settings)で変える。
| option | 既定値 | 意味 |
|---|---|---|
gemmaUrl | http://127.0.0.1:1234/v1/chat/completions | OpenAI 互換のエンドポイント |
gemmaModel | gemma-4-12b-it-mlx-bench@6bit | 検出に使うモデル |
onDetectorError | block | Gemma 失敗時に送信を止める(regex-only なら正規表現だけで伏せて送る) |
detectionScope | full | エンジンが書く文(スキル一覧、MCP の説明、システムプロンプトの固定セクション)も Gemma で検査する。fast ではそれらを正規表現と既知の値だけにする(速いが、利用者が書いたスキルの説明などの人名が通り抜ける) |
bashRestore | off | 伏せ字を含む Bash を拒否し、元の値をシェルに渡さない。with-approval では毎回の承認を経て戻す(通信系に見えるコマンドは名前で拒否するが、すり抜けうる) |
images | drop | 伏せられない画像・文書を除外する(pass で素通し) |
架空の患者記録(氏名・電話・メール)を置いたディレクトリで claude -p --plugin-dir を実行した。
| 確認したこと | 結果 |
|---|---|
| Claude が読んだ Read の結果 | 担当: __PII_PERSON_1__ / 連絡先: __PII_PHONE_1__ / メール: __PII_EMAIL_1__ |
Claude の Edit(old_string: 担当: __PII_PERSON_1__) | 実行直前に戻り、ファイルには 担当: 山田太郎(確認済み) と実名で書き込まれた |
transcript JSONL の message.content(送信される側) | 原文 0 件 |
| Gemma が 503 を返したとき | ツール結果は [privacy-gateway] … Claude に送っていません に差し替わり、Claude は内容を見られなかった |
| 所要時間 | 約 3 分(偽の即答 Gemma では 14 秒。ほぼすべてが Gemma の推論待ち) |
実際の API リクエスト本文(ANTHROPIC_BASE_URL を記録用の中継に向けて取得) | mod なしでは氏名・メール 3 種・git のユーザー名が生で送られた。mod ありでは 3 リクエストとも原文 0 件 |
| 同上、CLAUDE.md とユーザー情報 | 伏せ字で届いた(業務用メールアドレスは __PII_EMAIL_2__、The user's email address is __PII_EMAIL_1__、Git user: __PII_PERSON_2__)。差し止め 0 件、48 秒(fast 相当) |
detectionScope: full(既定)の E2E | 差し止め 0 件、伏せられないブロック 0 件。ただし 290 秒。Gemma で 121 か所を検査し、MCP のツール説明が送り直し込みで最長 129 秒かかった |
claude plugin test . で 25 件のテストが通る(hook の結線、伏せ字の往復、承認、検査範囲、検算、fail-closed の分岐)。
DiffusionGemma(mlx-community/diffusiongemma-26B-A4B-it-4bit、mlx-vlm 0.7.4)は LM Studio が未対応なので、 just diffusion で mlx-vlm のサーバを立て、just run-diffusion で検出器を切り替える。
| 測定 | Gemma 4 12B(LM Studio, MLX 6bit) | DiffusionGemma 26B A4B(mlx-vlm, 4bit) |
|---|---|---|
| 検出プロンプト 5 例(医療・チャット・コード・伏せ字・英語) | 4 例正解(コメント中の Tanaka を見落とし) | 5 例正解 |
| キャッシュの効かない 1,500 字 × 1 件 | 13.2 秒 | 3.0 秒 |
| 同 × 7 件同時(起動直後を模擬) | 50〜86 秒(7 件とも 30 秒超) | 最遅 21〜32 秒。ただし Metal の打ち切り(Impacting Interactivity)で 0〜4 件が HTTP 500 |
| E2E(API リクエスト本文で確認) | 原文 0 件、差し止め 0 件、48 秒 | 原文 0 件、memory セクションを 1 件差し止め、71 秒。GitHub / Twitter のハンドルまで伏せた |
速さの差は拡散方式による出力の速さより、アクティブ 3.8B の MoE で前処理が軽いことによる(出力は 5 トークン前後しかない)。 mlx-vlm の DiffusionGemma は負荷がかかると GPU の打ち切りが起き、--max-num-seqs 1 と MLX_MAX_OPS_PER_BUFFER / MLX_MAX_MB_PER_BUFFER を小さくしても消えなかった。5xx は送り直すが、使い切ると差し止めになる。
$.http.fetch の 30 秒の上限と競争になる: 起動直後は CLAUDE.md・memory などを一斉に Gemma で検査する。 12B(MLX 6bit)は 1,500 字の塊の前処理に 12 秒前後かかり、並ぶと 30 秒を超えて切られ、.catch が中身ごと差し止めていた (CLAUDE.md やユーザー情報が Claude に届かない。漏れはしない)。対策として、エンジン固定の一覧は正規表現だけにし、 検査結果を行単位で使い回し、切られた問い合わせは送り直す(LM Studio は切断後も前処理を終えてキャッシュするので、送り直すと速く返る)。 最後の検証では LM Studio のキャッシュが温まっていて送り直しは起きておらず、冷えた状態で送り直しが効くことはテストでしか確かめていないtoolUseResult(画面描画用の構造化記録)はエンジンが作ったまま保存する。送信はされないdeveloper.runtimeLogVerbosityLevel = 3)でリクエスト本文を ~/.lmstudio/server-logs/ に平文で記録する-p(ヘッドレス)の出力は伏せ字のまま: ui.render が無いため。turn.complete で戻せるかは未検証full)では MCP のツール説明・スキル一覧・CLAUDE.md をすべて Gemma に通すので、MCP サーバの多い環境では 最初の応答まで約 5 分かかった。detectionScope: fast なら約 1 分。ほかの対策案は、検出結果のキャッシュを $.store に永続化する、 速いモデル(DiffusionGemma、E4B)に替える、などTanaka)は見落とした。既知の名簿を辞書として先に登録する仕組みは未実装sec-default mod が prompt.context / prompt.section をユーザーの mod から守るので、CLAUDE.md とシステムプロンプトは伏せられない。allowManagedModsOnly が有効だとこの mod 自体が読み込まれない$ は関数の引数にできない($.noun.event(...) の形で呼び出し箇所に綴る)。必要な操作をクロージャにして渡す(hooks/gateway.ts の Port)read / update に渡す atom は、呼び出すファイル自身の const で定義する(claude plugin validate が静的に読む)session.append の受け手を立てられない(next なしの答えは捨てられ、下にも実装がない)。行の書き換えは単体テストと E2E で確かめた.catch も無いと、エンジンは next(e) を代行する(fail-open)。.catch の猶予は 1 秒なので、その中で Gemma は呼ばない$ の呼び出し($.http.fetch など)が走っている間は減らない。一方 $.http.fetch 自体には 1 回 30 秒の上限があり(型定義に記載なし、観測)、超えると HooksError で切られる--debug-file)に検査 1 回ごとの JSON を 1 行出す({"plugin":"privacy-gateway","event":"mask","site":"context:claudeMd","depth":"full","chars":11666,"added":1,"ms":10738})。中身は載せないhooks/register.ts 326 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { APPROVAL_REASON, BASH_DENIAL, BASH_TOOL, NETWORK_DENIAL, isLocalTool, isNetworkBoundCommand } from './egress'
5import { createGateway, settingsOf, type DetectionScope, type MaskDepth, type Port } from './gateway'
6import { containsToken, unmaskDeep, unmaskText } from './mask'
7import { BLOCKED_TEXT, blockedMessage, maskMessage } from './message'
8
9/**
10 * 対応表はセッションの状態に置く。モジュール変数だけだと hot reload で消え、
11 * それ以前に渡した伏せ字を戻せなくなるため。
12 *
13 * read / update に渡す参照は、エンジンの検査が読めるよう呼び出すファイル自身の const で定義する。
14 */
15const VAULT = atom({ plugin: 'privacy-gateway', key: 'vault' } as const, { entries: [] })
16
17/**
18 * `fast` でも Gemma に通すシステムプロンプトのセクション。auto memory は利用者について書かれうる。
19 * 残りはエンジン固定の説明文なので、`fast` では正規表現と既知の値の置き換えだけにする
20 * (20 前後のセクションを毎回問い合わせると、最初の応答まで数分待つことになる。E2E で約 3 分)。
21 */
22const USER_DATA_SECTIONS = new Set(['memory'])
23
24/**
25 * エンジンが自前で書く一覧・定型文の添付。`fast` ではこれらを Gemma に通さない。
26 *
27 * Why not 逆に「利用者のデータを含む種類だけ Gemma に通す」: 種類名はビルドごとに増減し、未知の種類
28 * (IDE の選択範囲、MCP リソースなど)に利用者のデータが入りうる。未知の種類は Gemma に通す側に倒す。
29 * Why not 既定(`full`)でも省く: スキル一覧や MCP の説明には利用者や第三者の書いた文が入り、
30 * 人名が通り抜ける(2026-10-06 のセキュリティレビューの指摘)。省くのは利用者が `fast` を選んだときだけ。
31 */
32const ENGINE_PROSE_ATTACHMENTS = new Set([
33 // 2.1.289 の E2E で観測した、エンジン固定の一覧と定型文
34 'skill_listing',
35 'agent_listing_delta',
36 'deferred_tools_delta',
37 'mcp_instructions_delta',
38 'remote_session_change',
39 'language',
40 'model',
41 'date',
42 'total_tokens_reminder',
43 'todo_reminder',
44 'plan_mode',
45 'plan_mode_exit',
46 'plan_mode_reentry',
47 'auto_mode',
48 'auto_mode_exit',
49])
50
51const RESTORE_FAILED = 'privacy-gateway: 伏せ字を元の値に戻せなかったため、ツールを実行しませんでした。'
52
53const OMITTED_DESCRIPTION = '[privacy-gateway] このツールの説明は PII を検査できなかったため省略しました。'
54
55/**
56 * 検出に失敗して差し止めたことを記す debug ログの 1 行。失敗の文面は Gemma の出力を含みうるので載せない。
57 */
58function blockedLine(site: string, kind: string | undefined): string {
59 return JSON.stringify({ plugin: 'privacy-gateway', event: 'blocked', site, kind: kind ?? 'unknown' })
60}
61
62/**
63 * 添付の検査の深さ。`fast` のときだけ、エンジン自身が書いた一覧・定型文を正規表現だけにする。
64 */
65function attachmentDepth(scope: DetectionScope, type: string, isByEngine: boolean): MaskDepth {
66 const isEngineProse = isByEngine && ENGINE_PROSE_ATTACHMENTS.has(type)
67
68 return scope === 'fast' && isEngineProse ? 'regex' : 'full'
69}
70
71/**
72 * システムプロンプトのセクションの検査の深さ。
73 */
74function sectionDepth(scope: DetectionScope, name: string): MaskDepth {
75 return scope === 'fast' && !USER_DATA_SECTIONS.has(name) ? 'regex' : 'full'
76}
77
78/**
79 * Claude に届くものは伏せ字にし、戻すのは「手元で完結するツールの実行直前」と「画面表示」だけにする。
80 *
81 * 伏せる: prompt.submit(入力)/ session.append(会話に積まれる全行)/ prompt.attachment・
82 * prompt.context・prompt.section・tool.describe(リクエストごとに組み立てられる添付・CLAUDE.md・
83 * システムプロンプト・ツールの説明)
84 * 戻す: tool.call(ローカルツールの引数。Bash は既定で戻さず、bashRestore: with-approval なら tool.check で承認を求めてから)/
85 * ui.render(表示だけ。保存される行は伏せ字のまま)
86 *
87 * 伏せる側の hook には必ず .catch を付ける。付けないと失敗時にエンジンが next(e) を代行し、
88 * 原文がそのまま Claude に届く(fail-open)ため。
89 */
90
91export const register: Register = (on, options) => {
92 const settings = settingsOf(options)
93 const gateway = createGateway(settings)
94
95 on('session.start', async ($, e, next) => {
96 await gateway.ready(() => read($, VAULT))
97
98 return next(e)
99 })
100
101 // ---- Claude に届く前に伏せる ----
102
103 on('prompt.submit', async ($, e, next) => {
104 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
105 const port: Port = {
106 fetch: (url, init) => $.http.fetch(url, init),
107 load: () => read($, VAULT),
108 save: change => update($, VAULT, change),
109 log: text => $.ui.log(text),
110 status: text => $.ui.status(text),
111 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
112 }
113 const text = await gateway.mask(port, e.text, { site: 'prompt.submit' })
114 const context =
115 e.context === undefined
116 ? undefined
117 : await Promise.all(e.context.map(entry => gateway.mask(port, entry, { site: 'prompt.submit:context' })))
118
119 return next({ ...e, text, ...(context !== undefined && { context }) })
120 }).catch(($, e, next) => {
121 $.ui.log(blockedLine('prompt.submit', next.error?.kind), { to: 'debug' })
122
123 return { drop: BLOCKED_TEXT }
124 })
125
126 on('session.append', async ($, e, next) => {
127 const isSentToModel = e.message.role !== undefined
128 const isModelOutput = e.door === 'response'
129 if (!isSentToModel || isModelOutput) return next(e)
130
131 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
132 const port: Port = {
133 fetch: (url, init) => $.http.fetch(url, init),
134 load: () => read($, VAULT),
135 save: change => update($, VAULT, change),
136 log: text => $.ui.log(text),
137 status: text => $.ui.status(text),
138 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
139 }
140 const isEngineAttachment = e.door === 'attachment' && e.origin.kind === 'engine'
141 const site = `session.append:${e.door}:${e.message.name ?? e.message.type}`
142 const depth = attachmentDepth(settings.scope, e.message.name ?? '', isEngineAttachment)
143 const message = await maskMessage(e.message, {
144 mask: text => gateway.mask(port, text, { site, depth }),
145 images: settings.images,
146 onUnmasked: type => port.trace({ event: 'unmasked-block', site, type }),
147 })
148
149 return next({ ...e, message })
150 }).catch(($, e, next) => {
151 const isSentToModel = e.message.role !== undefined
152 const isModelOutput = e.door === 'response'
153 if (!isSentToModel || isModelOutput) return next(e)
154
155 $.ui.log(blockedLine(`session.append:${e.door}`, next.error?.kind), { to: 'debug' })
156
157 return next({ ...e, message: blockedMessage(e.message) })
158 })
159
160 on('prompt.attachment', async ($, e, next) => {
161 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
162 const port: Port = {
163 fetch: (url, init) => $.http.fetch(url, init),
164 load: () => read($, VAULT),
165 save: change => update($, VAULT, change),
166 log: text => $.ui.log(text),
167 status: text => $.ui.status(text),
168 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
169 }
170 const attached = await next(e)
171 if (attached.text === null) return attached
172
173 const depth = attachmentDepth(settings.scope, e.type, e.origin.kind === 'engine')
174
175 return { text: await gateway.mask(port, attached.text, { site: `attachment:${e.type}`, depth }) }
176 }).catch(($, e, next) => {
177 $.ui.log(blockedLine(`attachment:${e.type}`, next.error?.kind), { to: 'debug' })
178
179 return { text: null }
180 })
181
182 on('prompt.context', async ($, e, next) => {
183 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
184 const port: Port = {
185 fetch: (url, init) => $.http.fetch(url, init),
186 load: () => read($, VAULT),
187 save: change => update($, VAULT, change),
188 log: text => $.ui.log(text),
189 status: text => $.ui.status(text),
190 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
191 }
192 const context = await next(e)
193 const blocks = await Promise.all(
194 context.blocks.map(async block => ({
195 ...block,
196 text: await gateway.mask(port, block.text, { site: `context:${block.name}` }),
197 })),
198 )
199
200 return { ...context, blocks }
201 }).catch(($, e, next) => {
202 $.ui.log(blockedLine('prompt.context', next.error?.kind), { to: 'debug' })
203
204 return { blocks: [] }
205 })
206
207 on('prompt.section', async ($, e, next) => {
208 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
209 const port: Port = {
210 fetch: (url, init) => $.http.fetch(url, init),
211 load: () => read($, VAULT),
212 save: change => update($, VAULT, change),
213 log: text => $.ui.log(text),
214 status: text => $.ui.status(text),
215 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
216 }
217 const section = await next(e)
218 if (section.text === null) return section
219
220 const depth = sectionDepth(settings.scope, e.name)
221
222 return { text: await gateway.mask(port, section.text, { site: `section:${e.name}`, depth }) }
223 }).catch(($, e, next) => {
224 $.ui.log(blockedLine(`section:${e.name}`, next.error?.kind), { to: 'debug' })
225
226 return { text: null }
227 })
228
229 on('tool.describe', async ($, e, next) => {
230 // $ は引数にできないので、この hook の $ を包んだ入出力をその場で作る
231 const port: Port = {
232 fetch: (url, init) => $.http.fetch(url, init),
233 load: () => read($, VAULT),
234 save: change => update($, VAULT, change),
235 log: text => $.ui.log(text),
236 status: text => $.ui.status(text),
237 trace: fields => $.ui.log(JSON.stringify({ plugin: 'privacy-gateway', ...fields }), { to: 'debug' }),
238 }
239 const described = await next(e)
240 // 組み込みツールの説明はエンジン固定の文章。MCP サーバなど外から来た説明だけを Gemma に通す
241 const isByEngine = e.provider.plugin === 'engine'
242 const depth = isByEngine || settings.scope === 'fast' ? 'regex' : 'full'
243 const site = `describe:${e.tool}`
244
245 return { ...described, description: await gateway.mask(port, described.description, { site, depth }) }
246 }).catch(($, e, next) => {
247 $.ui.log(blockedLine(`describe:${e.tool}`, next.error?.kind), { to: 'debug' })
248
249 return { description: OMITTED_DESCRIPTION }
250 })
251
252 // ---- 手元で実行する直前にだけ戻す ----
253
254 on('tool.check', async ($, e, next) => {
255 const verdict = await next(e)
256 const isBashWithToken = e.tool === BASH_TOOL && containsToken(e.input)
257 const isDenied = verdict.decision === 'deny'
258 if (!isBashWithToken || isDenied) return verdict
259 if (settings.bashRestore === 'off') return { decision: 'deny', reason: BASH_DENIAL }
260
261 // 設定で許可済みのコマンドでも、元の値に戻して実行するなら毎回確かめる
262 return { decision: 'ask', reason: APPROVAL_REASON }
263 }).catch(($, e, next) => {
264 const isBashWithToken = e.tool === BASH_TOOL && containsToken(e.input)
265 if (!isBashWithToken) return next(e)
266
267 return { decision: 'deny', reason: BASH_DENIAL }
268 })
269
270 on('tool.call', async ($, e, next) => {
271 await gateway.ready(() => read($, VAULT))
272 const hasPlaceholder = containsToken(e)
273 if (!hasPlaceholder) return next(e)
274 if (isLocalTool(e.tool)) return next(unmaskDeep(e, gateway.vault))
275 if (e.tool !== BASH_TOOL) return next(e)
276
277 // tool.check を経ない呼び出しもあるので、ここでも既定(off)の Bash には戻さない
278 if (settings.bashRestore === 'off') return { deny: BASH_DENIAL }
279
280 const restored = unmaskDeep(e, gateway.vault)
281 // 承認はオートモードの判定器が下すこともあるので、通信系に見えるコマンドには承認に関わらず戻さない
282 const isNetworkBash = restored.tool === BASH_TOOL && isNetworkBoundCommand(restored.command)
283 if (isNetworkBash) return { deny: NETWORK_DENIAL }
284
285 return next(restored)
286 }).catch(() => ({ deny: RESTORE_FAILED }))
287
288 // ---- 画面に描くときだけ戻す(保存・送信される行は伏せ字のまま) ----
289
290 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
291 await gateway.ready(() => read($, VAULT))
292 const text = unmaskText(e.props.text, gateway.vault)
293 if (text === e.props.text) return next(e)
294
295 return next({ ...e, props: { ...e.props, text } })
296 })
297
298 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
299 await gateway.ready(() => read($, VAULT))
300 const text = unmaskText(e.props.text, gateway.vault)
301 if (text === e.props.text) return next(e)
302
303 return next({ ...e, props: { ...e.props, text } })
304 })
305
306 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
307 await gateway.ready(() => read($, VAULT))
308 if (!containsToken(e.props.input) && !containsToken(e.props.output)) return next(e)
309
310 const input = unmaskDeep(e.props.input, gateway.vault)
311 const hasOutput = 'output' in e.props
312 const props = hasOutput
313 ? { ...e.props, input, output: unmaskDeep(e.props.output, gateway.vault) }
314 : { ...e.props, input }
315
316 return next({ ...e, props })
317 })
318
319 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
320 await gateway.ready(() => read($, VAULT))
321 if (!containsToken(e.props.output)) return next(e)
322
323 return next({ ...e, props: { ...e.props, output: unmaskDeep(e.props.output, gateway.vault) } })
324 })
325}
326hooks/egress.ts 55 lines1/**
2 * 伏せ字を元の値に戻してから実行してよいツール。どれも手元のファイルに作用し、
3 * 引数そのものを外部サービスへ送らない。Bash は何でも実行できるので別扱い(下の BASH_TOOL)。
4 *
5 * Why not 全ツールで戻す: WebFetch / WebSearch / MCP(SaaS 連携)は引数が外部に出るので、
6 * 戻すとせっかく伏せた値をそのまま第三者に渡してしまう。これらは伏せ字のまま実行させる。
7 */
8const LOCAL_TOOLS = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Glob', 'Grep'])
9
10export const BASH_TOOL = 'Bash'
11
12/**
13 * 既定(`bashRestore: off`)で、伏せ字を含む Bash を拒否するときの理由。Claude が読む。
14 *
15 * Why not 伏せ字のまま実行させる: `git commit -m "__PII_PERSON_1__ の件"` のように伏せ字がそのまま
16 * 書き込まれる。Why not 名前の拒否リストを通れば戻す: クォート(`c''url`)や変数展開(`curl${IFS}`)で
17 * シェルの解釈とずれ、すり抜けを塞ぎきれない(2026-10-06 のセキュリティレビューの指摘)。
18 */
19export const BASH_DENIAL =
20 'privacy-gateway: Bash には伏せ字(__PII_…__)を元の値に戻して渡しません。' +
21 'ファイルの検索や編集は Read / Grep / Edit / Write を使ってください。シェルで元の値が要るときはユーザーに実行を依頼してください。'
22
23export function isLocalTool(tool: string): boolean {
24 return LOCAL_TOOLS.has(tool)
25}
26
27/**
28 * 通信に使われることの多いコマンド。`/usr/bin/curl` のようなパス指定も拾う。`bashRestore: with-approval` のときだけ使う。
29 *
30 * 承認(`tool.check` の ask)を下すのは「モードごとの判定者」で、オートモードでは判定器(Claude)が許可しうる。
31 * 人の目を通らずに伏せ字が戻って外へ出ることを減らすため、伏せ字を戻した Bash がこれに当たれば承認に関わらず拒否する。
32 * 名前で見るだけの最善努力の網で、クォート(`c''url`)、変数展開(`curl${IFS}`)、`python3 -c` などは抜ける。
33 * 抜けさせないことが要るなら、既定の `bashRestore: off` のまま使う。
34 */
35const NETWORK_COMMAND =
36 /(^|[\s;&|(`$/])(curl|wget|http|https|xh|gh|ssh|scp|sftp|rsync|nc|ncat|socat|telnet|ftp|aws|gcloud|az|mail|sendmail)(?=\s|$)|\bgit\s+(push|send-email)\b|\b(npm|pnpm|yarn|bun)\s+publish\b|\bdocker\s+push\b/
37
38export function isNetworkBoundCommand(command: string): boolean {
39 return NETWORK_COMMAND.test(command)
40}
41
42export const NETWORK_DENIAL =
43 'privacy-gateway: 外部と通信しうるコマンドに伏せ字(__PII_…__)が含まれているため、元の値に戻して実行しませんでした。' +
44 '伏せ字を含まない形で実行するか、ユーザー自身に実行を依頼してください。'
45
46/**
47 * 承認ダイアログに出す理由。
48 *
49 * Why not 戻した後のコマンドをそのまま見せる: この理由はオートモードの判定器(Claude)にも渡りうるので、
50 * 元の値を入れると Claude に PII が届く。伏せ字のまま、戻すことだけを知らせる。
51 */
52export const APPROVAL_REASON =
53 'privacy-gateway: このコマンドに含まれる伏せ字(__PII_…__)を元の値に戻して実行します。' +
54 '値が外部に送られないことを確かめてから許可してください。'
55hooks/gateway.ts 153 lines1import type { PluginOptions } from 'claude-code'
2
3import type { PrivacyGatewayVault } from '../types'
4import { detectByGemma, type Fetch, type GemmaCache, type GemmaConfig } from './detect/gemma'
5import { detectByRegex, type Finding } from './detect/regex'
6import { replaceKnownValues } from './mask'
7import type { ImagePolicy } from './message'
8import { createVault, mergeSnapshots, type Vault } from './vault'
9
10/**
11 * hook が `$` から作って渡す入出力。`$` は `$.noun.event(...)` の形でしか綴れず、
12 * 関数の引数にできないため、必要な操作だけをクロージャにして受け取る。
13 */
14export type Port = {
15 fetch: Fetch
16 load: () => Promise<PrivacyGatewayVault>
17 save: (change: (held: PrivacyGatewayVault) => PrivacyGatewayVault) => Promise<unknown>
18 log: (text: string) => void
19 status: (text: string) => void
20 /**
21 * debug ログに 1 行の JSON を書く。中身(原文・伏せ字の対応)は決して載せず、種類・量・時間だけを書く。
22 */
23 trace: (fields: Record<string, string | number | boolean>) => void
24}
25
26export type Settings = {
27 gemma: GemmaConfig
28 /**
29 * Gemma が失敗したとき送信を止めるか(true)、正規表現だけで伏せて送るか(false)。
30 */
31 isFailClosed: boolean
32 images: ImagePolicy
33 /**
34 * `full`: エンジン固定の文章(スキル一覧、システムプロンプトの固定セクションなど)も Gemma で検査する。
35 * `fast`: それらは正規表現と既知の値の置き換えだけにする。起動直後の検査が `$.http.fetch` の 30 秒の
36 * 上限に収まりやすいが、利用者の書いたスキルの説明などに入った人名は通り抜ける。
37 */
38 scope: DetectionScope
39 /**
40 * `off`: 伏せ字を含む Bash は戻さずに拒否する。`with-approval`: 毎回の承認を経て戻す。
41 */
42 bashRestore: BashRestore
43}
44
45export type DetectionScope = 'full' | 'fast'
46
47export type BashRestore = 'off' | 'with-approval'
48
49export function settingsOf(options: PluginOptions): Settings {
50 const stringOf = (key: string, fallback: string) => {
51 const value = options[key]
52 const isSet = typeof value === 'string' && value !== ''
53
54 return isSet ? value : fallback
55 }
56
57 return {
58 gemma: {
59 url: stringOf('gemmaUrl', 'http://127.0.0.1:1234/v1/chat/completions'),
60 model: stringOf('gemmaModel', 'gemma-4-12b-it-mlx-bench@6bit'),
61 },
62 isFailClosed: stringOf('onDetectorError', 'block') === 'block',
63 images: stringOf('images', 'drop') === 'pass' ? 'pass' : 'drop',
64 scope: stringOf('detectionScope', 'full') === 'fast' ? 'fast' : 'full',
65 bashRestore: stringOf('bashRestore', 'off') === 'with-approval' ? 'with-approval' : 'off',
66 }
67}
68
69export type Gateway = {
70 vault: Vault
71 /**
72 * 保存済みの対応表を読み込む。伏せ字を割り当てる前に必ず待つ
73 * (読み込み前に割り当てると、既に Claude に渡した番号と衝突する)。
74 */
75 ready: (load: Port['load']) => Promise<void>
76 /**
77 * テキストを伏せ字にする。新しく見つけた値は対応表に足して保存する。
78 */
79 mask: (port: Port, text: string, scope?: MaskScope) => Promise<string>
80}
81
82/**
83 * `regex` では Gemma に問い合わせず、正規表現と既知の値の置き換えだけで伏せる。
84 */
85export type MaskDepth = 'full' | 'regex'
86
87/**
88 * どこから呼ばれた検査か(`site` は trace に載せる名前)と、検査の深さ。
89 */
90export type MaskScope = { site: string; depth?: MaskDepth }
91
92export function createGateway(settings: Settings): Gateway {
93 const vault = createVault()
94 const cache: GemmaCache = new Map()
95 let hydrating: Promise<void> | undefined
96 let isFailureLogged = false
97
98 const ready = (load: Port['load']): Promise<void> => {
99 const pending =
100 hydrating ??
101 load().then(
102 saved => vault.absorb(saved),
103 (error: unknown) => {
104 // 読み込みに失敗したら次の hook でやり直す
105 hydrating = undefined
106 throw error
107 },
108 )
109 hydrating = pending
110
111 return pending
112 }
113
114 const detectWithGemma = async (port: Port, text: string): Promise<Finding[]> => {
115 try {
116 return await detectByGemma(port.fetch, settings.gemma, cache, text)
117 } catch (error) {
118 if (settings.isFailClosed) throw error
119
120 if (!isFailureLogged) {
121 isFailureLogged = true
122 port.log(`privacy-gateway: Gemma で検出できないため正規表現だけで伏せています(${String(error)})`)
123 }
124
125 return []
126 }
127 }
128
129 const mask = async (port: Port, text: string, scope: MaskScope = { site: 'direct' }) => {
130 await ready(port.load)
131 const isBlank = text.trim() === ''
132 if (isBlank) return text
133
134 const depth = scope.depth ?? 'full'
135 const startedAt = Date.now()
136 const before = vault.size()
137 const byGemma = depth === 'full' ? await detectWithGemma(port, text) : []
138 const findings = [...detectByRegex(text), ...byGemma]
139 for (const finding of findings) vault.tokenFor(finding.category, finding.value)
140
141 const added = vault.size() - before
142 if (added > 0) {
143 await port.save(held => mergeSnapshots(held, vault.snapshot()))
144 port.status(`privacy-gateway: ${vault.size()} 件を伏せ字で送信中`)
145 }
146 port.trace({ event: 'mask', site: scope.site, depth, chars: text.length, added, ms: Date.now() - startedAt })
147
148 return replaceKnownValues(text, vault)
149 }
150
151 return { vault, ready, mask }
152}
153hooks/mask.ts 71 lines1import { TOKEN_SOURCE, isToken, type Vault } from './vault'
2
3const TOKEN_GLOBAL = new RegExp(TOKEN_SOURCE, 'g')
4
5const ASCII_WORD = /[A-Za-z0-9_]/
6
7/**
8 * 正規表現の構文文字だけを退避する(u フラグでは `-` などの余計な退避が構文エラーになる)。
9 */
10const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
11
12/**
13 * 英数字で始まる・終わる値は単語の境界でだけ置き換える(`kei` で `keiko` を壊さない)。
14 * かな漢字の値には単語境界がないので部分一致で置き換える。
15 */
16function patternOf(value: string): string {
17 const head = ASCII_WORD.test(value[0] ?? '') ? '(?<![A-Za-z0-9_])' : ''
18 const tail = ASCII_WORD.test(value[value.length - 1] ?? '') ? '(?![A-Za-z0-9_])' : ''
19
20 return `${head}${escape(value)}${tail}`
21}
22
23/**
24 * 既知の値をすべて伏せ字に置き換える。いま検出されなかった値も、一度覚えた値なら必ず伏せる。
25 *
26 * 伏せ字そのものを選択肢の先頭に置き、既存の伏せ字の内側が別の値として置き換わらないようにする。
27 */
28export function replaceKnownValues(text: string, vault: Vault): string {
29 const values = vault.knownValues()
30 if (values.length === 0) return text
31
32 const pattern = new RegExp([TOKEN_SOURCE, ...values.map(patternOf)].join('|'), 'gu')
33
34 return text.replace(pattern, match => (isToken(match) ? match : (vault.tokenOf(match) ?? match)))
35}
36
37/**
38 * 伏せ字を元の値に戻す。対応表にない伏せ字(Claude が作った番号など)はそのまま残す。
39 */
40export function unmaskText(text: string, vault: Vault): string {
41 return text.replace(TOKEN_GLOBAL, token => vault.valueOf(token) ?? token)
42}
43
44/**
45 * オブジェクトや配列の中の文字列をすべて元に戻す。ツールの引数や表示用の props に使う。
46 */
47export function unmaskDeep<T>(value: T, vault: Vault): T {
48 if (typeof value === 'string') return unmaskText(value, vault) as T
49 if (Array.isArray(value)) return value.map(item => unmaskDeep(item, vault)) as T
50
51 const isRecord = typeof value === 'object' && value !== null
52 if (!isRecord) return value
53
54 return Object.fromEntries(
55 Object.entries(value).map(([key, item]) => [key, unmaskDeep(item, vault)]),
56 ) as T
57}
58
59/**
60 * 値の中に伏せ字が 1 つでもあるか。
61 */
62export function containsToken(value: unknown): boolean {
63 if (typeof value === 'string') return new RegExp(TOKEN_SOURCE).test(value)
64 if (Array.isArray(value)) return value.some(containsToken)
65
66 const isRecord = typeof value === 'object' && value !== null
67 if (!isRecord) return false
68
69 return Object.values(value).some(containsToken)
70}
71hooks/message.ts 103 lines1type Block = { type: string; [field: string]: unknown }
2
3type Message = { content: Block[] }
4
5export type ImagePolicy = 'drop' | 'pass'
6
7export const BLOCKED_TEXT =
8 '[privacy-gateway] PII の検出に失敗したため、この内容は Claude に送っていません。'
9
10const DROPPED_MEDIA_TEXT = '[privacy-gateway] 伏せ字にできない画像・文書を 1 件除外しました。'
11
12const MEDIA_TYPES = new Set(['image', 'document'])
13
14/**
15 * エンジンが元に戻すブロック。モデルの出力そのもので、伏せる対象ではない。
16 */
17const MODEL_OWN_TYPES = new Set(['thinking', 'redacted_thinking', 'tool_use'])
18
19const isMedia = (block: Block) => MEDIA_TYPES.has(block.type)
20
21type Masking = {
22 mask: (text: string) => Promise<string>
23 images: ImagePolicy
24 /**
25 * 伏せられずに送られる最上位のブロックを知らせる。エンジンは知らない種類のブロックを元に戻すので、
26 * hook からは書き換えも削除もできない。
27 */
28 onUnmasked: (type: string) => void
29}
30
31/**
32 * tool_result の content は文字列か、ブロックの配列のどちらか。
33 */
34async function maskToolResultContent(content: unknown, masking: Masking): Promise<unknown> {
35 if (typeof content === 'string') return masking.mask(content)
36 if (!Array.isArray(content)) return content
37
38 return maskBlocks(content as Block[], masking, true)
39}
40
41async function maskBlocks(blocks: readonly Block[], masking: Masking, isNested: boolean): Promise<Block[]> {
42 const masked: Block[] = []
43 for (const block of blocks) {
44 const isDroppedMedia = isMedia(block) && masking.images === 'drop'
45 if (isDroppedMedia) {
46 // 画像・文書は書き換えも追加もできず、削除だけが許されている
47 masked.push({ type: 'text', text: DROPPED_MEDIA_TEXT })
48 continue
49 }
50 if (block.type === 'text' && typeof block.text === 'string') {
51 masked.push({ ...block, text: await masking.mask(block.text) })
52 continue
53 }
54 if (block.type === 'tool_result') {
55 masked.push({ ...block, content: await maskToolResultContent(block.content, masking) })
56 continue
57 }
58 const isKnownPassThrough = isMedia(block) || MODEL_OWN_TYPES.has(block.type)
59 if (isKnownPassThrough) {
60 masked.push(block)
61 continue
62 }
63 if (isNested) {
64 // tool_result の中身は丸ごと書き換えられるので、知らない種類は JSON の文字列にして伏せる
65 masked.push({ type: 'text', text: await masking.mask(JSON.stringify(block)) })
66 continue
67 }
68 masking.onUnmasked(block.type)
69 masked.push(block)
70 }
71
72 return masked
73}
74
75/**
76 * 会話の 1 行のうち、モデルが読むテキストをすべて伏せ字にする。
77 */
78export async function maskMessage<M extends Message>(message: M, masking: Masking): Promise<M> {
79 return { ...message, content: await maskBlocks(message.content, masking, false) }
80}
81
82/**
83 * 検出に失敗した行の代わりに送る中身。原文は 1 文字も残さず、tool_result の対応だけ保つ。
84 */
85export function blockedMessage<M extends Message>(message: M): M {
86 const content: Block[] = []
87 for (const block of message.content) {
88 if (block.type === 'text') {
89 content.push({ type: 'text', text: BLOCKED_TEXT })
90 continue
91 }
92 if (block.type === 'tool_result') {
93 content.push({ ...block, content: BLOCKED_TEXT, is_error: true })
94 continue
95 }
96 if (isMedia(block)) continue
97
98 content.push(block)
99 }
100
101 return { ...message, content }
102}
103hooks/detect/gemma.ts 248 lines1import type { HttpInit, HttpResponse } from 'claude-code'
2
3import { TOKEN_SOURCE } from '../vault'
4import type { Finding } from './regex'
5
6export type GemmaConfig = { url: string; model: string }
7
8/**
9 * `$.http.fetch` を包んだ関数。`$` は呼び出し箇所でしか綴れないため、hook 側で作って渡す。
10 */
11export type Fetch = (url: string, init: HttpInit) => Promise<HttpResponse>
12
13/**
14 * Gemma に一度に渡す文字数。長いツール結果は行単位でこの大きさに切って並べて問い合わせる。
15 *
16 * `$.http.fetch` には 1 回 30 秒の上限がある(2.1.289 で観測。超えると HooksError で切られる)。
17 * LM Studio は前処理をほぼ 1 件ずつこなすので、1 件の待ち時間は「前に並ぶ件数 × 1 件の前処理時間」になる。
18 * Why not 3000 字: 起動直後に 7 件(各 1,300 トークン前後)が並び、後ろの数件が 30 秒を超えて差し止められた。
19 */
20const CHUNK_CHARS = 1500
21
22/**
23 * 1 回の検査で同時に投げる問い合わせ数。起動直後は複数の hook が同時に検査するので、合計が膨らまないよう小さく抑える。
24 */
25const CONCURRENCY = 2
26
27const CATEGORIES = new Set(['PERSON', 'ADDRESS', 'BIRTHDATE', 'ID', 'SECRET'])
28
29/**
30 * 文字(かな・漢字・ラテン文字)を 1 つも含まない行は人名も住所も持たないので問い合わせない。
31 */
32const HAS_LETTER = /[A-Za-z\u3040-\u30ff\u3400-\u9fff]/
33
34const TOKEN_EXACT = new RegExp(`^${TOKEN_SOURCE}$`)
35
36const SYSTEM_PROMPT = `You are a PII and secret detector. Read the user's text and list every substring that is personal or confidential data about a real individual.
37
38Types:
39- PERSON: a real person's name (family name, given name, full name, nickname, romanized name). A word followed by さん, 様, 氏, 君, ちゃん, 先生 or 医師 is a name. Names inside code comments and string literals count.
40- ADDRESS: a postal address or a part of one (prefecture, city, street, building)
41- BIRTHDATE: a date of birth
42- ID: an identifier tied to a person (patient ID, medical record number, insurance number, employee number, account number)
43- SECRET: a password, passphrase, token or other credential
44
45Do NOT list:
46- identifiers in source code (variable, function, property, type names) even if they look like names
47- placeholders of the form __PII_<TYPE>_<n>__
48- names of companies, products, services, or software (GitHub, Anthropic, Claude, octocat as a product mascot)
49- generic words and roles (患者, 医師, user)
50
51Copy each substring exactly as it appears in the text, character for character. Output JSON only, no prose, no code fence:
52{"entities":[{"text":"<exact substring>","type":"<TYPE>"}]}
53If there is nothing, output {"entities":[]}.`
54
55export class DetectorError extends Error {
56 override name = 'DetectorError'
57}
58
59/**
60 * Gemma に渡す塊と、その塊が答えを持つ行(キャッシュのキー)。
61 */
62export type Chunk = { text: string; keys: string[] }
63
64const keyOf = (line: string) => line.trim()
65
66/**
67 * 行の並びを CHUNK_CHARS 以下の塊にまとめる。1 行が長すぎるときだけ行の途中で切り、
68 * その行のキーは切った塊すべてに載せる。
69 */
70export function chunksOf(lines: readonly string[]): Chunk[] {
71 const chunks: Chunk[] = []
72 let current: Chunk = { text: '', keys: [] }
73 const flush = () => {
74 if (current.text !== '') chunks.push(current)
75 current = { text: '', keys: [] }
76 }
77 for (const line of lines) {
78 const isOverflowing = current.text.length + line.length > CHUNK_CHARS
79 if (isOverflowing) flush()
80
81 const isLongLine = line.length > CHUNK_CHARS
82 if (!isLongLine) {
83 current.text += line
84 current.keys.push(keyOf(line))
85 continue
86 }
87 for (let start = 0; start < line.length; start += CHUNK_CHARS) {
88 chunks.push({ text: line.slice(start, start + CHUNK_CHARS), keys: [keyOf(line)] })
89 }
90 }
91 flush()
92
93 return chunks
94}
95
96/**
97 * 同時に走らせる問い合わせを max 件に抑える。
98 */
99function limiter(max: number) {
100 let active = 0
101 const waiting: (() => void)[] = []
102
103 return <T>(task: () => Promise<T>): Promise<T> =>
104 new Promise<T>((resolve, reject) => {
105 const run = () => {
106 active++
107 task()
108 .then(resolve, reject)
109 .finally(() => {
110 active--
111 waiting.shift()?.()
112 })
113 }
114 if (active < max) run()
115 else waiting.push(run)
116 })
117}
118
119/**
120 * Gemma の答えから JSON を取り出し、本文に実在する部分文字列だけを残す(幻覚した値を伏せ字にしない)。
121 */
122export function findingsOf(answer: string, chunk: string): Finding[] {
123 const start = answer.indexOf('{')
124 const end = answer.lastIndexOf('}')
125 const hasObject = start !== -1 && end > start
126 if (!hasObject) throw new DetectorError('Gemma の応答に JSON がありません')
127
128 const parsed: unknown = JSON.parse(answer.slice(start, end + 1))
129 const entities = (parsed as { entities?: unknown }).entities
130 if (!Array.isArray(entities)) throw new DetectorError('Gemma の応答に entities がありません')
131
132 const findings: Finding[] = []
133 for (const entity of entities as { text?: unknown; type?: unknown }[]) {
134 const value = typeof entity.text === 'string' ? entity.text.trim() : ''
135 const isUsable = value.length >= 2 && chunk.includes(value) && !TOKEN_EXACT.test(value)
136 if (!isUsable) continue
137
138 const type = typeof entity.type === 'string' ? entity.type.toUpperCase() : ''
139 findings.push({ value, category: CATEGORIES.has(type) ? type : 'PII' })
140 }
141
142 return findings
143}
144
145/**
146 * 30 秒で切られた問い合わせと、サーバの一時的な失敗(5xx)を送り直す回数の上限(初回を含む)。
147 *
148 * LM Studio はクライアントが切れても前処理を終えてキャッシュするので、同じ問い合わせを送り直すと
149 * 続きから速く返る(1,500 字の塊で 12.3 秒 → 2.5 秒を観測)。mlx-vlm の DiffusionGemma は負荷がかかると
150 * Metal の打ち切り(Impacting Interactivity)で 500 を返すことがあり、送り直せば通る。
151 * 送り直している間も自分の `$` 呼び出しが走っているので、hook の持ち時間(10 秒)は減らない。
152 */
153const ATTEMPTS = 5
154
155async function fetchPatiently(fetch: Fetch, url: string, init: HttpInit): Promise<HttpResponse> {
156 for (let attempt = 1; ; attempt++) {
157 const isLastAttempt = attempt >= ATTEMPTS
158 try {
159 const response = await fetch(url, init)
160 const isServerError = response.status >= 500
161 if (!isServerError || isLastAttempt) return response
162 } catch (error) {
163 if (isLastAttempt) throw error
164 }
165 }
166}
167
168async function ask(fetch: Fetch, config: GemmaConfig, chunk: string): Promise<Finding[]> {
169 const response = await fetchPatiently(fetch, config.url, {
170 method: 'POST',
171 headers: { 'content-type': 'application/json' },
172 body: JSON.stringify({
173 model: config.model,
174 temperature: 0,
175 max_tokens: 1024,
176 messages: [
177 { role: 'system', content: SYSTEM_PROMPT },
178 { role: 'user', content: chunk },
179 ],
180 }),
181 })
182 if (!response.ok) throw new DetectorError(`Gemma が HTTP ${response.status} を返しました`)
183
184 const body = JSON.parse(response.text) as { choices?: { message?: { content?: string } }[] }
185 const answer = body.choices?.[0]?.message?.content
186 if (typeof answer !== 'string') throw new DetectorError('Gemma の応答に本文がありません')
187
188 return findingsOf(answer, chunk)
189}
190
191/**
192 * Gemma の結果を行ごとに覚えるキャッシュ。キーは前後の空白を除いた行、値はその行を含めて問い合わせた
193 * 塊の結果。問い合わせ中の行は同じ Promise を待つ。
194 *
195 * Why not 塊単位で覚える: CLAUDE.md のように同じ本文が枠の文言だけ変えて何度も届く(prompt.context、
196 * 添付、session.append)。塊単位だと区切りがずれてキャッシュが効かず、二重の問い合わせが LM Studio を
197 * 詰まらせて `$.http.fetch` の 30 秒の上限を超えていた。見つけた値は対応表で全文に効くので、
198 * 一度検査した行をもう一度検査する必要はない。
199 */
200export type GemmaCache = Map<string, Promise<Finding[]>>
201
202/**
203 * 形の決まらない PII(氏名、住所、生年月日、個人に紐づく ID)をローカルの Gemma で拾う。
204 * 失敗は DetectorError で投げ、送るか止めるかは呼び出し側が決める。
205 */
206export async function detectByGemma(
207 fetch: Fetch,
208 config: GemmaConfig,
209 cache: GemmaCache,
210 text: string,
211): Promise<Finding[]> {
212 const known: Promise<Finding[]>[] = []
213 const fresh: string[] = []
214 const freshKeys = new Set<string>()
215 for (const line of text.split(/(?<=\n)/)) {
216 const key = keyOf(line)
217 const isWorthAsking = HAS_LETTER.test(key) && !freshKeys.has(key)
218 if (!isWorthAsking) continue
219
220 const cached = cache.get(key)
221 if (cached !== undefined) {
222 known.push(cached)
223 continue
224 }
225 freshKeys.add(key)
226 fresh.push(line)
227 }
228
229 const limit = limiter(CONCURRENCY)
230 const asked: Promise<Finding[]>[] = []
231 const coveringByKey = new Map<string, Promise<Finding[]>[]>()
232 for (const chunk of chunksOf(fresh)) {
233 const pending = limit(() => ask(fetch, config, chunk.text))
234 asked.push(pending)
235 for (const key of chunk.keys) coveringByKey.set(key, [...(coveringByKey.get(key) ?? []), pending])
236 }
237 for (const [key, covering] of coveringByKey) {
238 const answer = Promise.all(covering).then(results => results.flat())
239 cache.set(key, answer)
240 // 失敗した行は次回やり直せるようにキャッシュから外す
241 answer.catch(() => {
242 if (cache.get(key) === answer) cache.delete(key)
243 })
244 }
245
246 return (await Promise.all([...known, ...asked])).flat()
247}
248hooks/detect/regex.ts 129 lines1export type Finding = { value: string; category: string }
2
3type Rule = {
4 category: string
5 pattern: RegExp
6 /**
7 * 形だけでは決められないものを検算で落とす(チェックディジット、桁数)。
8 */
9 accepts?: (match: string) => boolean
10}
11
12const PLACEHOLDER_EMAIL_DOMAIN = /(^|\.)(example\.(com|org|net)|example|invalid|test|localhost)$/i
13const NOREPLY_LOCAL_PART = /^no-?reply$/i
14
15const digitsOf = (text: string) => text.replace(/\D/g, '')
16
17/**
18 * 公開を前提にしたアドレス(例示用ドメイン、noreply)は伏せない。
19 */
20function isPrivateEmail(email: string): boolean {
21 const [local = '', domain = ''] = email.split('@')
22 const isPlaceholderDomain = PLACEHOLDER_EMAIL_DOMAIN.test(domain)
23 const isNoreply = NOREPLY_LOCAL_PART.test(local) || domain.endsWith('users.noreply.github.com')
24
25 return !isPlaceholderDomain && !isNoreply
26}
27
28/**
29 * 日本の電話番号は市外局番込みで 10 桁、携帯・IP 電話は 11 桁。日付(03-12-2026 など)を落とす。
30 */
31function isPhoneNumber(match: string): boolean {
32 const digits = digitsOf(match.replace(/^\+81/, '0'))
33
34 return digits.length === 10 || digits.length === 11
35}
36
37/**
38 * マイナンバー(個人番号)のチェックディジット検算。総務省令の計算式に従う。
39 */
40export function isMyNumber(match: string): boolean {
41 const digits = digitsOf(match)
42 if (digits.length !== 12) return false
43
44 let sum = 0
45 for (let n = 1; n <= 11; n++) {
46 const p = Number(digits[11 - n])
47 const q = n <= 6 ? n + 1 : n - 5
48 sum += p * q
49 }
50 const remainder = sum % 11
51 const expected = remainder <= 1 ? 0 : 11 - remainder
52
53 return Number(digits[11]) === expected
54}
55
56/**
57 * クレジットカード番号の Luhn 検算。
58 */
59export function passesLuhn(match: string): boolean {
60 const digits = digitsOf(match)
61 let sum = 0
62 for (let i = 0; i < digits.length; i++) {
63 const digit = Number(digits[digits.length - 1 - i])
64 const isDoubled = i % 2 === 1
65 const value = isDoubled ? digit * 2 : digit
66 sum += value > 9 ? value - 9 : value
67 }
68
69 return sum % 10 === 0
70}
71
72const RULES: readonly Rule[] = [
73 {
74 category: 'SECRET',
75 pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
76 },
77 { category: 'SECRET', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
78 { category: 'SECRET', pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
79 { category: 'SECRET', pattern: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/g },
80 { category: 'SECRET', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
81 { category: 'SECRET', pattern: /\bsk-(?:proj-)?[A-Za-z0-9]{20,}\b/g },
82 { category: 'SECRET', pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
83 { category: 'SECRET', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
84 {
85 category: 'EMAIL',
86 pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
87 accepts: isPrivateEmail,
88 },
89 {
90 category: 'PHONE',
91 pattern: /(?<![\d-])(?:\+81[- ]?\d{1,4}[- ]?\d{1,4}[- ]?\d{3,4}|0\d{1,4}-\d{1,4}-\d{3,4}|0[5789]0\d{8})(?![\d-])/g,
92 accepts: isPhoneNumber,
93 },
94 {
95 category: 'MYNUMBER',
96 pattern: /(?<![\d-])\d{4}[ -]?\d{4}[ -]?\d{4}(?![\d-])/g,
97 accepts: isMyNumber,
98 },
99 {
100 category: 'CARD',
101 pattern: /(?<![\d-])[3-6]\d{3}(?:[ -]?\d{2,4}){2,4}(?![\d-])/g,
102 accepts: match => {
103 const length = digitsOf(match).length
104 const isCardLength = length >= 14 && length <= 19
105
106 return isCardLength && passesLuhn(match)
107 },
108 },
109 { category: 'POSTAL', pattern: /〒\s?\d{3}-?\d{4}/g },
110]
111
112/**
113 * 形の決まった PII と秘密情報を正規表現で拾う。Gemma が落ちていても必ず効く層。
114 */
115export function detectByRegex(text: string): Finding[] {
116 const findings: Finding[] = []
117 for (const rule of RULES) {
118 for (const match of text.matchAll(rule.pattern)) {
119 const value = match[0]
120 const isAccepted = rule.accepts?.(value) ?? true
121 if (!isAccepted) continue
122
123 findings.push({ value, category: rule.category })
124 }
125 }
126
127 return findings
128}
129hooks/vault.ts 100 lines1import type { PrivacyGatewayVault } from '../types'
2
3/**
4 * 伏せ字の書式。`__PII_PERSON_1__` のように ASCII の識別子として閉じた形にする。
5 *
6 * Why not `⟦PERSON_1⟧` や `[PERSON_1]`: 前者は非 ASCII のためシェルやエディタ経由で
7 * 揺れやすく、後者は Markdown のリンク記法や配列リテラルと見分けがつかない。
8 * 識別子の形なら Claude はコード中でも文中でも一字一句そのまま書き戻す。
9 */
10export const TOKEN_SOURCE = '__PII_[A-Z]+_\\d+__'
11
12const TOKEN_EXACT = new RegExp(`^${TOKEN_SOURCE}$`)
13
14export type Vault = {
15 /**
16 * 値に対応する伏せ字を返す。初めての値なら種類ごとの連番で新しく割り当てる。
17 */
18 tokenFor: (category: string, value: string) => string
19 tokenOf: (value: string) => string | undefined
20 valueOf: (token: string) => string | undefined
21 /**
22 * 既知の値を長い順に返す。短い値が長い値の一部を先に置き換えないようにするため。
23 */
24 knownValues: () => readonly string[]
25 size: () => number
26 snapshot: () => PrivacyGatewayVault
27 /**
28 * 保存済みの対応表を取り込む。連番は取り込んだ伏せ字の続きから振る。
29 */
30 absorb: (saved: PrivacyGatewayVault) => void
31}
32
33export function isToken(text: string): boolean {
34 return TOKEN_EXACT.test(text)
35}
36
37export function createVault(): Vault {
38 const byValue = new Map<string, string>()
39 const byToken = new Map<string, { value: string; category: string }>()
40 const counters = new Map<string, number>()
41 let sortedValues: readonly string[] | undefined
42
43 const remember = (token: string, value: string, category: string) => {
44 byValue.set(value, token)
45 byToken.set(token, { value, category })
46 sortedValues = undefined
47 }
48
49 const tokenFor = (category: string, value: string) => {
50 const known = byValue.get(value)
51 if (known !== undefined) return known
52
53 const next = (counters.get(category) ?? 0) + 1
54 counters.set(category, next)
55 const token = `__PII_${category}_${next}__`
56 remember(token, value, category)
57
58 return token
59 }
60
61 const absorb = (saved: PrivacyGatewayVault) => {
62 for (const [token, value, category] of saved.entries) {
63 const isAlreadyKnown = byToken.has(token)
64 if (isAlreadyKnown) continue
65
66 remember(token, value, category)
67 const serial = Number(token.match(/_(\d+)__$/)?.[1] ?? 0)
68 counters.set(category, Math.max(counters.get(category) ?? 0, serial))
69 }
70 }
71
72 return {
73 tokenFor,
74 tokenOf: value => byValue.get(value),
75 valueOf: token => byToken.get(token)?.value,
76 knownValues: () => {
77 sortedValues ??= [...byValue.keys()].sort((a, b) => b.length - a.length)
78 return sortedValues
79 },
80 size: () => byToken.size,
81 snapshot: () => ({
82 entries: [...byToken].map(([token, { value, category }]) => [token, value, category]),
83 }),
84 absorb,
85 }
86}
87
88/**
89 * 2 つの対応表を伏せ字単位で合わせる。並行して保存しても割り当てを失わないため。
90 */
91export function mergeSnapshots(
92 held: PrivacyGatewayVault,
93 mine: PrivacyGatewayVault,
94): PrivacyGatewayVault {
95 const tokens = new Set(held.entries.map(([token]) => token))
96 const added = mine.entries.filter(([token]) => !tokens.has(token))
97
98 return { entries: [...held.entries, ...added] }
99}
100types/index.d.ts 13 lines1/**
2 * 伏せ字と元の値の対応表。`[token, value, category]` の並び。
3 */
4export type PrivacyGatewayVault = {
5 entries: [token: string, value: string, category: string][]
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'privacy-gateway': { vault: PrivacyGatewayVault }
11 }
12}
13