LLMに渡すログや問い合わせ文には、メールアドレス、カード番号、電話番号、APIキーやトークンなどの秘密情報に見える文字列が混ざる。PII Guardrail を置くだけでは十分ではない。どの文字列を、input(モデルへ送る前)と output(モデル応答を返す前)のどちらで、mask と block のどちらとして扱うかを設定し、実際の表記で確かめる必要がある。
本記事では OrcaRouter の Guardrail を検証用API keyごとに分け、エコー応答を使って確認した。実在の個人情報・実在の秘密鍵は送っていない。pytestの「原文が応答にない/400で遮断された」という結果と、Matches feed の stage・action・detail を合わせて判定している。
まず結果:何を検証し、何が分かったか
| 検証シナリオ | 設定した防御 | live test | Matches feed | 判断 |
|---|---|---|---|---|
| 標準PII(入力) | input / mask、email・カード番号 | 2件 pass | input / mask を確認 | この合成値では有効 |
| 日本形式携帯番号(入力) | input / mask、custom regex追加前 | fail | matchなし | 標準設定では未検出 |
| 日本形式携帯番号(入力) | input / mask、custom regex追加後 | 3件 pass | input / mask を確認 | 検証した表記では有効 |
| カード番号遮断 | input / block | 1件 pass(400 guardrail_blocked) |
input / block を確認 | 遮断を確認 |
| 日本形式携帯番号(出力) | output / mask、custom regex追加後 | 3件 pass | output / mask を確認 | 検証した表記では有効 |
ここでいう「pass」は、マスク系では合成値の原文がcompletionに含まれないこと、block系ではHTTP 400かつ guardrail_blocked が返ることを意味する。エコー試験だけで「上流モデルが絶対に生値を見ていない」ことまでは証明できないため、Matches feed も確認できた行だけを Guardrail 発火まで確認済みとした。
共通の実験方法
テストは OpenAI互換の Chat Completions に、合成値を「そのまま返す」よう依頼して実行した。原文が応答に残れば fail、マスクされるか遮断されれば pass とする。live APIの実行は明示的に opt-in である。
ORCAROUTER_PII_TEST_PROFILE=<profile> \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
誤検知を調べる場合は、正規表現に一致しない near-miss を送り、原文が残ることを確認した。
ORCAROUTER_PII_TEST_PROFILE=<profile> \
ORCAROUTER_PII_TEST_KIND=negative \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
API keyの値、実在のPII、request ID、IP、match本文は記録していない。各profileとkey名の対応は以下に残している。
guardrailのprofileについて
各プロファイルは 1つの検証用 API key と、その key に明示的に紐付けた Guardrail の組です。キー値は .env にだけ置き、Guardrail の設定・Matches feed・pytest の結果を evidence.md に対応付けます。
| Profile |
.env のキー名 |
Template / stage / action | live ケース | 現在の状態 |
|---|---|---|---|---|
mask |
ORCAROUTER_API_KEY_MASK |
PII Shield / input / mask | email、カード、携帯番号 | 実測済み |
block |
ORCAROUTER_API_KEY_BLOCK |
PII Blocker / input / block | カード | 実測済み |
output |
ORCAROUTER_API_KEY_OUTPUT |
Response PII Redactor / output / mask | email、カード、携帯番号 | 実測済み |
jp_ids |
ORCAROUTER_API_KEY_JP_IDS |
Custom Info Redactor / input / mask | custom マイナンバー12桁形式、custom 郵便番号 | custom rule は live 成功、Matches 未確認 |
identity |
ORCAROUTER_API_KEY_IDENTITY |
custom / semantic policy / input / mask | 日本語氏名、住所 | 未設定・探索的 |
secrets |
ORCAROUTER_API_KEY_SECRETS |
Secrets Blocker / input / block | OpenAI key 形式、AWS key 形式、JWT 形式、GitHub token 形式 | 初回 live は4 failed。実効設定を要確認 |
ORCAROUTER_PII_TEST_PROFILE は上表の Profile を指定する。指定しない場合は mask になる。profile ごとにテスト対象を固定しているため、設定していない entity を誤って live API へ送らない。
各 profile は既定で「検出されるべき」positive ケースを実行する。ORCAROUTER_PII_TEST_KIND=negative を指定すると、その profile の near-miss を送り、正常な値を mask / block していないことを確認する。negative 実行後は Matches feed に該当 entity の match がないことも確認する。
コンソールで作る設定
既存の mask / block
この2つはすでに live で確認済みである。mask の custom entity jp_mobile_phone は、Guardrail の version、RE2 正規表現、mask tag を evidence.md へ追記する。
output
-
Response PII Redactortemplate から Guardrail を作る。 -
stage: output、action: maskと、email / phone / credit_card を確認する。 -
ORCAROUTER_API_KEY_OUTPUTの key にだけ紐付ける。input rule を同じ key に混在させない。 - raw-content logging は off にする。
jp_ids
- input / mask の PII rule を作る。
-
jp_mynumberは予約済みのため custom entity 名に使わない。初回 live test でも12桁ダミー値を検出できなかったため、built-in の対応を記事で主張しない。 - custom entity を2つ追加する。名前・正規表現・mask tag はコンソールの生成 JSON を証拠として保存する。
-
jp_mynumber_format:\b[0-9]{12}\bで12桁形式だけを扱う。番号の真正性は検証しない。 -
jp_postal_code_format:\b[0-9]{3}-[0-9]{4}\bで123-4567形式を扱う。
-
ORCAROUTER_API_KEY_JP_IDSに紐付ける。
identity
日本語氏名・住所は、単純な正規表現だけで網羅的・低誤検知に検出できるとは扱わない。利用する policy(custom regex、LLM judge、またはアプリ側二次防衛)を決めてから key を紐付ける。この profile の結果は、設定した限定的な表記にしか一般化しない。
secrets
Secrets Blocker template から input / block の Guardrail を作る。これは PII Shield と分けて検証し、OpenAI / AWS / JWT / GitHub token の合成値を使う。実在する token や秘密鍵は使わない。
実行コマンド
live 実行の opt-in は .env に保存せず、毎回コマンドで渡す。
# 既存の input / mask 回帰
ORCAROUTER_PII_TEST_PROFILE=mask \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
# 既存 custom phone の誤検知確認
ORCAROUTER_PII_TEST_PROFILE=mask \
ORCAROUTER_PII_TEST_KIND=negative \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
# 既存の input / block
ORCAROUTER_PII_TEST_PROFILE=block \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
# output / mask
ORCAROUTER_PII_TEST_PROFILE=output \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
# 日本固有の形式
ORCAROUTER_PII_TEST_PROFILE=jp_ids \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
# secrets の block
ORCAROUTER_PII_TEST_PROFILE=secrets \
ORCAROUTER_RUN_LIVE_TESTS=1 \
uv run pytest
identity は policy の検出範囲を決めるまで実行しない。各 run の直後に Matches feed で profile に対応する Guardrail の rule type・stage・action・entity・件数を確認し、raw match、request ID、key 値は保存しない。
シナリオ1:標準PII Shieldを入力でマスクする
設定
検証用の mask API key に、PII Shield の input / mask Guardrail(Matches上の名称: article-0-4-input-mask-v1)を紐付けた。対象は email と credit card、および後述する日本携帯番号のcustom entityである。
概念的には次の構成である。実際にはUIで作成・keyへattachmentした。
{
"rules": [{
"type": "pii",
"stage": "input",
"action": "mask",
}]
}
初回結果
email とカード番号はpassした一方、11桁・ハイフン付きの日本携帯番号形式はfailした。Matches feedにも email と credit card の input / mask だけが残り、電話番号のmatchはなかった。
| ケース | pytest | Matches feed |
|---|---|---|
| pass | input / mask |
|
| credit card | pass | input / mask |
| 日本形式携帯番号 | fail | matchなし |
これは標準の phone entity 全般が機能しないことを示すものではない。この表記を、この設定では検出できなかったという範囲の結果である。
シナリオ2:日本形式携帯番号をcustom entityで補う
設定
シナリオ1と同じinput Guardrailに、jp_mobile_phone というcollisionしないcustom entityを追加した。この実験では、コンソールに保存した正規表現文字列そのものを証跡へ残していないため、ここでは検証できた対象を 090-1234-5678 形式とだけ記す。全ての日本電話番号表記を網羅する設定ではない。
input側へ追加した設定はoutput側には複製されない。
結果
電話番号単体で 1 passed(4.48秒)、email・電話番号・カード番号の回帰で 3 passed(3.22秒) だった。Matches feedにも jp_mobile_phone の input / mask が残り、回帰実行ではemail・カード番号・電話番号が各1件ずつ発火した。
続けて、電話番号のnear-missと通常の参照値をnegative testで実行したところ、2 passed(2.90秒) だった。この2例はマスクされなかった。ただし、このnegative runについてMatches feedの未発火確認はしていない。
シナリオ3:カード番号は入力時点でblockする
設定
block API keyには、別の PII Blocker を input / block として紐付けた(Matches上の名称: article-0-4-input-block-v1)。対象は credit_card である。
{
"rules": [{
"type": "pii",
"stage": "input",
"action": "block",
"entities": ["credit_card"]
}]
}
結果
カード番号の合成値で 1 passed(1.80秒)。このpassはcompletionを受け取った意味ではなく、期待どおりHTTP 400・guardrail_blocked が返ったことを意味する。Matches feedでも credit_card の input / block を1件確認した。
つまり、入力を伏せて処理を続けたい場合は mask、そのリクエスト自体を拒否したい場合は block という設計を、同じケースで区別して確認できた。
シナリオ4:Response PII Redactorを出力に設定する
設定
output API keyに、Response PII Redactor を output / mask として別途紐付けた(Matches上の名称: article-0-4-output-mask-v1)。email・credit cardに加え、シナリオ2と同じ jp_mobile_phone custom entityをこのGuardrailにも追加した。
{
"rules": [{
"type": "pii",
"stage": "output",
"action": "mask",
"entities": ["email", "credit_card", "jp_mobile_phone"]
}]
}
初回結果と修正後の結果
custom entityを追加する前は、emailとカード番号はpass、携帯番号はfailだった。input用Guardrailのcustom entityが、output用Guardrailに存在しなかったためである。
追加後は電話番号単体で 1 passed(2.74秒)、3件回帰で 3 passed(3.62秒) になった。Matches feedではemail・カード番号・jp_mobile_phone のすべてに output / mask を確認した。near-missのnegative testも 1 passed(2.34秒) だったが、このrunのMatches未発火は未確認である。
この結果から、inputとoutputは「同じ設定が効く場所」ではなく、それぞれを別の防御境界として設定・テストする必要がある。
シナリオ5:個人番号12桁・郵便番号を形式ベースでマスクする
設定
jp_ids API keyに、Custom Info Redactor を input / mask として設定した。当初、jp_mynumber というcustom entity名はコンソールでbuilt-in entityとの衝突として拒否された。また12桁の合成値と郵便番号は初回にともにfailした。
そこで、built-in検出を前提にせず、collisionしない名前のcustom format ruleを設定した。
{
"rules": [{
"type": "pii",
"stage": "input",
"action": "mask",
"entities": ["jp_mynumber_format", "jp_postal_code_format"],
"custom_entities": [
{
"name": "jp_mynumber_format",
"pattern": "\\b[0-9]{12}\\b",
"checksum": "",
"mask_with": "[JP-MYNUMBER]"
},
{
"name": "jp_postal_code_format",
"pattern": "\\b[0-9]{3}-[0-9]{4}\\b",
"checksum": "",
"mask_with": "[JP-POSTAL-CODE]"
}
]
}]
}
結果と限界
custom rule追加後、12桁数字列と 123-4567 形式の郵便番号で 2 passed(3.82秒)。桁数不足・不完全な郵便番号を使うnegative testも 2 passed(2.76秒) だった。
ただし、positive/negativeともMatches feedの照合は未完了である。またこの設定は、番号の真正性、マイナンバーであること、全ての郵便番号表記を判定するものではない。12桁数字列すべてをマスクするため、業務データでの誤検知リスクを別途評価する必要がある。
シナリオ6:Secrets Blockerは未達だった
想定した設定
secrets API keyでは、Secrets Blockerを input / block とし、OpenAI API key形式、AWS access key形式、JWT形式、GitHub token形式の合成値を対象にする計画だった。
結果
4ケースすべてがcompletionまで進み、4 failed(4.36秒) だった。すなわち、この試験時点の実効ポリシーは4件をinputでblockしていない。
この結果を「OrcaRouterはこれらの形式に対応していない」とは解釈しない。Guardrailのkey attachment、有効化状態、stage / action、entity指定、GitHub用custom patternのいずれかが実効設定と一致していない可能性があるためだ。Matches feedも採取していない。時間制約のため再設定・再試験はせず、未解決の失敗として残す。
記事としての結論
今回確認できたのは、「PII Shieldを有効化した」ことではなく、限定した合成値について次の境界が動作したことである。
- emailとカード番号は、input / maskでマスクされた。
- カード番号は、input / blockでリクエストを拒否できた。
-
090-1234-5678形式の日本携帯番号は、custom entity追加後にinput / outputの両方でマスクされた。 - 個人番号12桁・郵便番号は、custom format ruleで応答非漏えい試験を通過した。ただしMatches照合は未完了である。
反対に、Secrets Blocker、氏名・住所、表記ゆれへの網羅性、実運用データでの誤検知率は未検証である。Guardrailを導入する際は、標準entity名だけで防御範囲を判断せず、実際に流れる表記をpositive/negativeの両方でテストし、Matches feedのstage・actionと対応付けて記録するのが安全である。