マガジン

LLMにツールを持たせる時の安全設計:引数検証・権限・プロンプトインジェクション

AIを使いこなす

Claudeで執筆し、GPTで自動校閲・修正しています。

机の上で、分けた鍵束と送信前に保留された封筒を手作業で確認している静物
記事の内容をもとに制作したAI生成イメージです。実際の製品画面や検証結果の写真ではありません。

LLMにツールを持たせるときの安全設計は、突き詰めると「モデルが攻撃者に操られても被害が限定される作りにする」ことです。プロンプトで「悪い指示は無視して」と書く、入力を禁止語でフィルタする、といった対策だけでは足りません。OWASPのプロンプトインジェクション対策チートシートも、入力のスクリーニングは検知・ログ・レート制限には役立つが、LLMはプロンプト内の指示とデータを境界として区別しないため、セキュリティ境界にはならないと整理しています(OWASP LLM Prompt Injection Prevention Cheat Sheet)。 実務で効くのは、次の3点をモデルの外側のコードで強制することです。 1. ツールを絞る:そのタスクに必要なものだけを渡す 2. 引数と権限を実行側で検証する:モデルが出した引数も、呼び出しユーザーの権限で判定する 3. 影響の大きい操作は人が承認する:送信・削除・支払いなど この記事では、この考え方を「ツール別リスク評価表」と「実装前チェックリスト」に落とします。表の中のツール名や数値は、すべて説明用の例です。実測データや、私が実際に動かした結果ではありません。

この記事の目次
  1. モデルの出力は「外部から来た入力」として扱う
  2. 防御層を4つに分けて考える
  3. ツール別リスク評価表(説明用の例)
  4. 引数検証と権限判定の骨子
  5. 承認は「操作そのもの」に結びつける
  6. 実装前チェックリスト
  7. 自分のツールで評価表を作る依頼文
  8. まず着手する順番

モデルの出力は「外部から来た入力」として扱う

ツール呼び出しでは、モデルが {"tool": "send_email", "to": "...", "body": "..."} のような引数を生成し、アプリがそれを実行します。問題は、モデルに渡る文章に、ユーザー本人が書いたものだけでなく、検索結果、Webページ、メール本文、ファイルの中身が混ざる点です。そこに攻撃者の指示が紛れていれば、モデルは正規のツール呼び出しの形で攻撃を出力します。OWASPのチートシートは、エージェント特有の攻撃として、攻撃者が制御するパラメータでツールを呼ばせる「ツール操作」や、作業記憶に偽情報を入れる「コンテキスト汚染」を挙げています。

OWASPは、こうした被害が膨らむ原因を「過剰なエージェンシー」として整理しています。過剰な機能、過剰な権限、過剰な自律性の3つです。対策も、必要最小限の権限、重要な操作での人の承認、操作の監視と監査が中心です(OWASP LLM06:2025 Excessive Agency)。

つまり設計の出発点は「インジェクションを100%防ぐ」ではなく、「防ぎきれなかった場合に何ができてしまうか」を先に決めることです。

防御層を4つに分けて考える

  • ①ツール選定、実装する場所:ツール定義、やること:不要なツールを渡さない。読み取り専用と書き込みを別ツールに分ける
  • ②引数検証、実装する場所:実行コード、やること:未知のツールを拒否、許可リストで値を検証、機密らしき文字列を弾く
  • ③権限判定、実装する場所:実行コード、やること:モデルの意思ではなく、呼び出しユーザーの権限と対象リソースで可否を決める
  • ④承認・制限、実装する場所:承認画面・ゲートウェイ、やること:高影響操作は人が確認。呼び出し回数・再試行・コストに上限を置く

これらはOWASPのAI Agent Security Cheat Sheetが述べる、最小権限とツール別スコープ、引数検証、人の承認、出力検証、レート制限、ログ記録の考え方に沿った整理です。

注意したいのは、スキーマ準拠と安全性は別物だという点です。OpenAIのFunction callingの strict モードは、関数呼び出しがスキーマに従うことを確実にする機能として説明されています。ただ、型が合っていることと「その注文IDが本人のものか」「そのURLが許可先か」は別問題で、後者は自分のコードで判定する必要があります。これは私の設計上の見解で、ドキュメントに書かれた保証ではありません。

ツール別リスク評価表(説明用の例)

自社のツールに当てはめるためのたたき台です。リスクの高低は、影響の大きさと取り消しにくさから私が付けた例で、公式の分類ではありません。

  • FAQ検索(読み取り)、主な懸念:検索結果内の指示混入、他社情報の混入、最低限の制御:検索範囲を公開文書に限定、結果は「データ」として区切って渡す、人の承認:不要
  • 注文照会(読み取り)、主な懸念:他人の注文IDを指定される、最低限の制御:注文IDの形式検証、ログインユーザーの所有確認、人の承認:不要
  • 外部URL取得、主な懸念:内部アドレスへのアクセス、取得先ページからの再注入、最低限の制御:ホストの許可リスト、社内IP拒否、取得結果は信頼しない、人の承認:許可外ドメインのみ
  • メール下書き作成、主な懸念:機密の本文混入、最低限の制御:本文の機密パターン検査、下書き止まり、人の承認:不要(送信しないため)
  • メール送信、主な懸念:外部への情報持ち出し、最低限の制御:宛先ドメインの許可リスト、送信数の上限、人の承認:必須
  • SQL実行、主な懸念:全件取得、書き換え、削除、最低限の制御:読み取り専用アカウント、固定クエリ+パラメータ化、LLM生成の自由SQLは避ける、人の承認:書き込みは必須
  • 返金・決済、主な懸念:金銭被害、二重実行、最低限の制御:金額上限、冪等キー、本人確認済みセッションのみ、人の承認:必須
  • ファイル削除・コード実行、主な懸念:不可逆な破壊、環境の乗っ取り、最低限の制御:サンドボックス、対象ディレクトリ固定、ネットワーク遮断、人の承認:必須

読み方のコツは「そのツールが最悪の引数で呼ばれたら何が起きるか」を1行で書けるかどうかです。書けないツールは、まだ設計が足りていません。なお、外部URL取得ではDNS解決後の接続先IPも検証し、リダイレクト先も含めて社内・ループバック・リンクローカル等の非公開アドレスへの接続を遮断してください。ホスト名だけの許可リスト確認では、DNS rebindingやリダイレクトによる内部到達を防げません。

引数検証と権限判定の骨子

次はPythonの骨子です。説明用に書いたもので、実行して検証したものではありません。orders や user は自分のアプリの実装に置き換えてください。

python
import re
from urllib.parse import urlparse

TOOLS = {
    "get_order": {"needs_approval": False},
    "fetch_url": {"needs_approval": False},
    "send_email": {"needs_approval": True},
}
ORDER_ID = re.compile(r"[A-Z]{2}\d{8}")
ALLOWED_HOSTS = {"docs.example.com"}
ALLOWED_MAIL_DOMAINS = {"example.com"}
SECRET_LIKE = re.compile(r"(api[_-]?key|password|secret)\s*[:=]", re.I)

def authorize(user, name, args):
    spec = TOOLS.get(name)
    if spec is None:
        raise PermissionError("未知のツール")
    if SECRET_LIKE.search(str(args)):
        raise ValueError("機密らしき文字列を含む引数")

    if name == "get_order":
        oid = args.get("order_id", "")
        if not ORDER_ID.fullmatch(oid):
            raise ValueError("注文IDの形式が不正")
        if not orders.belongs_to(oid, user.id):  # 呼び出しユーザーで判定
            raise PermissionError("対象外の注文")
    elif name == "fetch_url":
        parsed = urlparse(args.get("url", ""))
        if parsed.scheme != "https" or parsed.hostname not in ALLOWED_HOSTS:
            raise PermissionError("許可外URL")
        # 実際のHTTPクライアント側で、DNS解決後の全接続先IPと
        # リダイレクト先も検証し、非公開アドレスへの接続を拒否する
    elif name == "send_email":
        to = args.get("to", "")
        if to.rsplit("@", 1)[-1] not in ALLOWED_MAIL_DOMAINS:
            raise PermissionError("許可外ドメイン")
    return spec["needs_approval"]

ポイントは3つです。未知のツール名は黙って通さず拒否すること。権限の判定にモデルの発言を使わず、ログイン中のユーザーを使うこと。そして、この関数を通らずにツールを実行できる経路を作らないことです。この骨子の fetch_url はホスト名の確認だけでは不十分です。実際の接続先IPとリダイレクト先もHTTPクライアント側で検証し、非公開アドレスへの接続を拒否してください。OWASPのチートシートも、提案された引数の検証と呼び出し元の権限の強制を、モデルの外にある実行コードで行うよう求めています。

承認は「操作そのもの」に結びつける

「実行してよいですか?」という曖昧な確認では、承認後にモデルが引数を変えても通ってしまいます。OWASPのAI Agentチートシートは、承認を実行者・ツール名・対象・引数・有効期限に結びつけ、不可逆な操作には短命の承認情報と再利用防止を使うよう述べています。

承認画面では、次の項目をモデルの要約ではなく、実際に実行される値のまま表示します。

  • 誰の権限で実行するか
  • ツール名と対象(宛先、注文ID、ファイルパスなど)
  • 引数の実値(メール本文の全文、金額など)
  • 承認の有効期限

また、リスク判定をユーザーのプロンプト中のキーワードに頼らないことも重要です。OWASPは、操作・対象・引数・呼び出し元の権限から判断すべきで、プロンプト内の語の数ではリスクは決まらないとしています。

実装前チェックリスト

リリース前に、ツール1つにつき次を確認してください。

ツールの棚卸し

  • [ ] このタスクに本当に必要なツールだけを渡している
  • [ ] 読み取りと書き込みを別ツールに分けている
  • [ ] 最悪の引数で呼ばれた場合の被害を1行で書いた

引数と権限

  • [ ] 未知のツール名と未定義の引数を拒否する
  • [ ] 値の検証は許可リスト方式で、ワイルドカードを避けている
  • [ ] URL取得ではスキーム・ホストに加え、DNS解決後のIPとリダイレクト先も検証し、非公開アドレスへの接続を拒否している
  • [ ] 権限判定は呼び出しユーザーの権限で、実行コード側が行う
  • [ ] DBやAPIの資格情報は、そのツールに必要な最小権限(読み取り専用など)

注入への備え

  • [ ] 検索結果・Web・メール・ファイル由来のテキストを「信頼できない入力」として扱う
  • [ ] そのテキストを読んだ後に、外部送信系ツールを呼べる状況を洗い出した
  • [ ] 入力フィルタは検知とログ用で、防御の本体ではないと位置づけている

承認・制限・記録

  • [ ] 送信、削除、決済、管理操作は承認必須で、承認が操作内容に結びついている
  • [ ] 1分あたりの呼び出し回数、再試行、再帰の深さ、コストに上限がある
  • [ ] 呼び出しをログに残している(機密はマスク)。異常な呼び出し回数を検知できる

自分のツールで評価表を作る依頼文

ツール定義(名前、説明、引数)をAIに貼って、評価表の下書きを作らせることもできます。機密情報や実際のキーは、ダミーに置き換えてから貼ってください。

以下のツール定義を、LLMエージェントに持たせる前提でレビューしてください。
各ツールについて、(1)最悪の引数で呼ばれた時の被害、(2)必要な引数検証、
(3)呼び出しユーザー権限での判定が必要な箇所、(4)人の承認の要否と理由、
を表にしてください。不明な点は推測せず「要確認」と書いてください。
[ここにツール定義(ダミー化済み)]

AIが出した表は下書きです。被害の大きさの判断や承認の要否は、サービスの性質を知る人が最終的に決めてください。

まず着手する順番

すでにツールを渡している場合は、(1)書き込み・送信系ツールの洗い出し、(2)それらの承認と上限の追加、(3)読み取り系の所有権チェック、の順で進めると、被害の大きい経路から塞げます。引数検証の細かな正規表現は、その後で十分です。

出典・参考資料