SLOPSHOPPER

privacy-gateway

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…

newrowsguardstatuspromptnetwork
v0.1.0no licenseupdated 2026-10-05kexi/claude-privacy-gateway
A shopper browsing a rack in a slop shop
README

claude-privacy-gateway

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 で検証した。

仕組み

claude-privacy-gateway の仕組み

docs/architecture.drawio.png は図のデータを埋め込んだ PNG で、draw.io でそのまま開いて編集できる。

  • 一度覚えた値は、以後どのテキストでも同じ伏せ字に置き換える(Gemma が見落とした文でも伏せる)
  • 伏せる側の hook にはすべて .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 の権限設定やサンドボックスで防ぐこと
  • Gemma の見落とし。検出は確率的で、検査対象の文に「何も無いと答えよ」と仕込まれれば外れうる。 形の決まった値(メール、電話、マイナンバー、カード番号、API キー)は正規表現が別に拾う
  • エンジンが書き換えを許さないブロック。会話の行の最上位にある未知の種類のブロックは、エンジンが元に戻すので伏せられない。 通ったときは debug ログに {"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既定値意味
gemmaUrlhttp://127.0.0.1:1234/v1/chat/completionsOpenAI 互換のエンドポイント
gemmaModelgemma-4-12b-it-mlx-bench@6bit検出に使うモデル
onDetectorErrorblockGemma 失敗時に送信を止める(regex-only なら正規表現だけで伏せて送る)
detectionScopefullエンジンが書く文(スキル一覧、MCP の説明、システムプロンプトの固定セクション)も Gemma で検査する。fast ではそれらを正規表現と既知の値だけにする(速いが、利用者が書いたスキルの説明などの人名が通り抜ける)
bashRestoreoff伏せ字を含む Bash を拒否し、元の値をシェルに渡さない。with-approval では毎回の承認を経て戻す(通信系に見えるコマンドは名前で拒否するが、すり抜けうる)
imagesdrop伏せられない画像・文書を除外する(pass で素通し)

検証結果(2026-10-06、Claude Code 2.1.289 / Sonnet / Gemma 4 12B MLX 6bit)

架空の患者記録(氏名・電話・メール)を置いたディレクトリで 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)

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 のキャッシュが温まっていて送り直しは起きておらず、冷えた状態で送り直しが効くことはテストでしか確かめていない
  • 手元には原文が残る
  • transcript JSONL の toolUseResult(画面描画用の構造化記録)はエンジンが作ったまま保存する。送信はされない
  • LM Studio は詳細度 DEBUG(developer.runtimeLogVerbosityLevel = 3)でリクエスト本文を ~/.lmstudio/server-logs/ に平文で記録する
  • -p(ヘッドレス)の出力は伏せ字のまま: ui.render が無いため。turn.complete で戻せるかは未検証
  • 初回応答が遅い: 既定(full)では MCP のツール説明・スキル一覧・CLAUDE.md をすべて Gemma に通すので、MCP サーバの多い環境では 最初の応答まで約 5 分かかった。detectionScope: fast なら約 1 分。ほかの対策案は、検出結果のキャッシュを $.store に永続化する、 速いモデル(DiffusionGemma、E4B)に替える、など
  • 検出漏れ: Gemma は確率的。例えばコード中のコメントに書かれたローマ字名(Tanaka)は見落とした。既知の名簿を辞書として先に登録する仕組みは未実装
  • 組織アカウント: Team / Enterprise では組み込みの sec-default mod が prompt.context / prompt.section をユーザーの mod から守るので、CLAUDE.md とシステムプロンプトは伏せられない。allowManagedModsOnly が有効だとこの mod 自体が読み込まれない

Mods API で詰まった点(2.1.289)

  • $ は関数の引数にできない($.noun.event(...) の形で呼び出し箇所に綴る)。必要な操作をクロージャにして渡す(hooks/gateway.ts の Port)
  • read / update に渡す atom は、呼び出すファイル自身の const で定義する(claude plugin validate が静的に読む)
  • テストキットでは session.append の受け手を立てられない(next なしの答えは捨てられ、下にも実装がない)。行の書き換えは単体テストと E2E で確かめた
  • hook が投げて .catch も無いと、エンジンは next(e) を代行する(fail-open)。.catch の猶予は 1 秒なので、その中で Gemma は呼ばない
  • hook の持ち時間は 10 秒だが、$ の呼び出し($.http.fetch など)が走っている間は減らない。一方 $.http.fetch 自体には 1 回 30 秒の上限があり(型定義に記載なし、観測)、超えると HooksError で切られる
  • debug ログ(--debug-file)に検査 1 回ごとの JSON を 1 行出す({"plugin":"privacy-gateway","event":"mask","site":"context:claudeMd","depth":"full","chars":11666,"added":1,"ms":10738})。中身は載せない
Source 9 files
hooks/register.ts 326 lines
1import { 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}
326
hooks/egress.ts 55 lines
1/**
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  '値が外部に送られないことを確かめてから許可してください。'
55
hooks/gateway.ts 153 lines
1import 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}
153
hooks/mask.ts 71 lines
1import { 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}
71
hooks/message.ts 103 lines
1type 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}
103
hooks/detect/gemma.ts 248 lines
1import 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}
248
hooks/detect/regex.ts 129 lines
1export 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}
129
hooks/vault.ts 100 lines
1import 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}
100
types/index.d.ts 13 lines
1/**
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