Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

findUnmaskedPiiは逆マスク後だと辞書由来の復元値まで誤検知する

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

逆マスク後にPII検査すると、辞書由来の復元値まで生PIIと誤検知して正しい下書きを丸ごと落とす。

逆マスク後にPII検査すると、辞書由来の復元値まで生PIIと誤検知して正しい下書きを丸ごと落とす。

結論

逆マスク(unmask)後の出力にPII検査をかけると、教員が元々書いていた電話番号やメールアドレス(maskPIIの辞書に載っている正規の復元値)まで「生PIIのリーク」と誤検知し、正しい下書き全体をfail-closedで破棄してしまう。 検査をマスク前(トークン化されたままのマスク空間)に移すと、モデルが生成した「辞書に無い生PII」だけを検出できるようになり、検出力を落とさずに誤検知を無くせる。

症状

キミテラス-v2のAI下書き機能は、教員が入力した書式PII(電話番号・メールアドレス)をmaskPIIでトークン化 → モデルに渡して生成 → spec.unmaskで元の表記に戻す、という設計になっている。修正前のassistant-actions.tsは、逆マスクのに次のfail-closed検査をかけていた。

// 逆マスク後の出力にも PII 残存が無いか fail-closed で再チェック(ルール4・全文字列フィールド)。
for (const it of items) {
  for (const field of spec.strings(it)) {
    if (findUnmaskedPii(field, []).length > 0) {
      return { ok: false, reason: "pii_leak" };
    }
  }
}

itemsspec.unmask(it, dictionary)で既に逆マスク済み、つまり電話番号やメールアドレスが元の生の表記に戻った状態。ここにfindUnmaskedPiiをかけると、教員が最初から書いていた正しい連絡先まで「PIIが残っている」と判定され、pii_leakとして下書きが丸ごと破棄されていた。

同型の検査は連絡ドラフトのストリーミング配信(notice-draft-sse.ts)にもあった。こちらは逆マスク後の要素を検査し、辞書由来の復元値を含む要素をnotice_redactedとして無言で落としていた。

const unmasked = unmaskPII(el.text, dictionary);
// 逆マスク後の各要素にも PII 残存が無いか fail-closed(漏れた項目だけ落とす、ルール4)。
if (typeof unmasked !== "string" || unmasked.trim().length === 0) {
  send("notice_redacted", { index });
  index += 1;
  continue;
}
if (findUnmaskedPii(unmasked, []).length > 0) {
  send("notice_redacted", { index });
  index += 1;
  continue;
}

原因

maskPIIは電話番号やメールアドレスをトークンに置き換え、辞書(dictionary)に元の値を記録する。モデルがそのトークンをそのまま(または反復して)出力し、unmaskunmaskPIIで辞書引きして元表記に戻すと、その文字列は当然「PIIの形」をしている。しかしこれは辞書に載っている、教員自身が最初に書いた正規の値であって、モデルが生成した生PIIのリークではない。 findUnmaskedPiiはその文字列がトークン由来かどうかを区別せず、PIIの形をしているかどうかだけを見るため、逆マスク後にかけると両者を区別できない。

直す

検査を逆マスク(マスク空間)に移すよう変更した。

// 逆マスク**前**(マスク空間)で fail-closed 再チェック(ルール4・全文字列フィールド)。辞書由来の正規 PII は
// token 化済みでここでは PII 形に見えず、検出されるのは「モデルが生成した辞書に無い生 PII」(= 真のリーク) のみ。
for (const it of proposal) {
  for (const field of spec.strings(it)) {
    if (findUnmaskedPii(field, []).length > 0) {
      return { ok: false, reason: "pii_leak" };
    }
  }
}
// 逆マスクして元の表記に戻す(セクションごとの対象列)。
items = proposal.map((it) => spec.unmask(it, dictionary));

マスク空間では、辞書由来の値はトークンのまま(PIIの形をしていない)なので検査を通過する。一方、モデルがマスクの取りこぼしや幻覚で生のPIIをそのまま出力していた場合は、マスク空間の時点でその生PIIがそのまま現れるため引き続き検出できる。検出対象を「逆マスク後の全文字列」から「マスク空間の全文字列」に変えただけで、検出力は変わらない。

notice-draft-sse.ts側も、逆マスクする前にマスク空間で検査してからunmaskPIIを呼ぶ順序に入れ替えた。

再発防止

この2経路の是正は、これ以前に会話AIチャット(#1105)で同型の誤検知が見つかり、既に修正済みだったことを踏まえた横展開だった。「逆マスク後に検査する」という同じ設計ミスが、チャットとは別の2つのドラフト経路(おまかせ/連絡/予定/提出物ドラフト、連絡ドラフトのストリーミング配信)に、独立して残っていたことになる。修正と合わせて、両経路に「辞書由来の復元値は通す」「辞書に無い生PIIは中断・redactする」ことを固定するテストが追加されている。

よくある質問

Q1なぜ逆マスク後に検査すると誤検知するのですか?

maskPIIは教員が書いた電話番号やメールアドレスをトークンに置き換え辞書に記録する。逆マスクで元表記へ戻すとその文字列は当然PIIの形をしており、findUnmaskedPiiはそれがトークン由来の正規値か、モデルが生成した生PIIかを区別しないため。

Q2検査をマスク空間に移すと検出力は落ちませんか?

落ちない。辞書由来の正規値はトークン化されたままなのでマスク空間ではPIIの形に見えず検査を通過する。一方モデルがマスクを取りこぼして生PIIをそのまま出力した場合は、マスク空間の時点でその生PIIがそのまま現れるため引き続き検出できる。

Q3同じ問題は他の経路にも残っていましたか?

残っていた。2026-06-21以前に会話AIチャット(#1105)で同型の誤検知が見つかり修正済みだったが、おまかせ/連絡/予定/提出物ドラフトと連絡ドラフトのストリーミング配信の2経路には、独立して同じ設計のまま残っていた。

確認した環境

  • Next.js ^16.0.0
  • 2026-06-21 に横展開で修正(会話AIチャット #1105 の修正より後)

この記事の根拠

  • TypeScriptファイル 322〜353行目コミット 766ad46
  • TypeScriptファイル 322〜356行目コミット 422e43b
  • TypeScriptファイル 239〜252行目コミット 766ad46
  • TypeScriptファイル 239〜256行目コミット 422e43b

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。