findUnmaskedPiiは逆マスク後だと辞書由来の復元値まで誤検知する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
逆マスク後に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" };
}
}
}
itemsはspec.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)に元の値を記録する。モデルがそのトークンをそのまま(または反復して)出力し、unmask/unmaskPIIで辞書引きして元表記に戻すと、その文字列は当然「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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。