AIエージェントの安全設計

AIエージェントの安全設計。権限・外部資料・承認・検証を順に示し、AIの提案を人が確認する図。

AIに何を任せ、どこで止めるか。

AIエージェントは、文章を作るだけでなく、ツールやAPIを使ってデータを読み、送信や更新まで行えるAIです。任せられる作業が増えるほど、「プロンプトで丁寧に頼む」だけでは安全は決まりません。どの範囲を任せ、外部の資料をどう扱い、送信や変更をどこで止め、その仕組みが働くことをどう確かめるかを、順番に決める必要があります。

このページでは、PAP Researchの解説記事を、設計で決める順に並べました。各段階で「何を決めるか」を短く説明し、詳しい手順は個別の記事へ案内します。

このページで分かること:AIに任せる範囲と権限の決め方、外部資料に書かれた命令への備え、送信・変更の制御と人間承認の分担、安全対策が働くかの確かめ方。

決める順番の早見表

段階決めること最初に読む記事
1.任せる範囲対象・操作・利用者を決め、必要な権限だけを渡すAIの適用範囲と権限は、対象・操作・利用者で決める
2.外部資料の扱い命令として従う情報と、内容として読むだけのデータの境目命令階層と信頼境界とは? AIへの指示と外部資料を分ける基本
3.送信・変更の制御プロンプト・システムによる強制・人間承認の役割分担AIの送信をどう制御する? 指示・システムの強制・人間承認の役割
4.動作の確認許可・拒否・保留が想定どおりに働くかAIへ仕事を任せる前のチェックリスト――確認資料と止める条件まで決める

1.AIにどこまで任せるか――適用範囲と権限

最初に決めるのは、AIが扱う対象、行ってよい操作、誰の依頼として動くかです。「この仕事だけをして」とプロンプトに書くことと、システムがその範囲しか実行できないことは別です。範囲外の操作は、AIが断るだけでなく、アプリやAPIの側でも拒否されるようにします。

外部APIを使う場合は、仕事に必要な操作を先に列挙し、それに合う権限だけを接続します。OWASP Top 10 for LLM Applications 2025は、LLMを使うシステムに与えた機能・権限・自律性が過剰なことによるリスクを「Excessive Agency」(LLM06)として挙げています。

2.外部資料の命令に従わせない――信頼境界とプロンプトインジェクション

AIエージェントは、Webページ、メール、文書、ツールの返却値など、外部の情報を読み込みながら作業します。その中に「この内容を転送せよ」のような文が含まれていても、それは作業の材料であって、従うべき指示ではありません。命令として扱う情報と、内容として読むだけのデータの境目(信頼境界)を、設計の段階で決めておきます。

外部の資料などに紛れ込ませた文でAIに意図しない動作をさせる攻撃はプロンプトインジェクションと呼ばれ、OWASP Top 10 for LLM Applications 2025の最初の項目(LLM01)です。区切り記号やXMLタグで資料を分けることは役に立ちますが、それだけで資料内の命令を無効にはできません。資料から許可のない操作が起きないよう、権限と実行側の制御を組み合わせます。

3.送信や変更をどう管理するか――実行側の制御と人間承認

メールの送信、データの更新、機械の操作など、外部に影響が残る操作は、プロンプトでの指示、システムによる条件の検証、人による判断の三つに役割を分けます。AIが指示を守った割合が高くても、それは試した条件での結果であり、常に守る保証にはなりません。重大な操作は、指示が守られなかった場合でも実行側で止まる構成にします。

人間承認(Human-in-the-Loop)は、何でも人に確認させればよいという意味ではありません。拒否する操作、範囲を狭めて続ける操作、人の確認を待つ操作を分けておくと、低リスクの作業を止めすぎずに済みます。

4.安全対策が働くかを確かめる――許可・拒否・保留の確認

設計した制限は、想定どおりに働くことを確かめて初めて意味を持ちます。許可された作業が通ることだけでなく、範囲外の要求が実行窓口で止まること、確認できない条件があるときに作業が保留されることを、別々の試験として計画します。

確認の種類確かめること例(架空の問い合わせ対応AI)
許可許可された対象・操作だけが実行される担当チケットの状態を「対応中」に更新でき、何を更新したかが利用者に伝わる
拒否範囲外の要求が、AIの返答だけでなく実行側でも止まる担当外のチケットの更新や削除を求めても、APIの側で拒否される
保留・停止必要な確認ができないとき、作業が進まない認証の期限切れや応答不明のときは再送せず、状態を確かめてから人が再開を判断する

確認の結果は、どの対策を、どの用途に、なぜ適用したかとあわせて記録しておくと、モデルや業務が変わったときに見直しやすくなります。

AIの回答は変わる前提で設計する

同じ質問でも、AIの答えは時期や条件によって変わることがあります。PAP Researchの定点観測でも、時刻・最新情報・モデル名の答え方などに変化が記録されています。安全対策をプロンプトとAIの判断だけに頼らず、権限と実行側の制御で支えるのはこのためです。観測の整理は「ChatGPTの回答が変わるのはなぜ? 定点観測で分かった変化と、言えないこと」をご覧ください。

模擬APIで確かめた権限制御

担当内の操作が通り、権限外の操作が止まることを、ローカルの模擬HTTP APIで確認しました。13ケースでHTTP応答とデータの変更を期待結果と照合し、すべて一致しました。これは実行側の制御を確かめる小さな検証であり、LLMの回答品質や実サービスの安全性を測った結果ではありません。

条件と実施環境

実施日時:2026-10-05T21:38:42+09:00。実行環境:Windows/Python 3.12.14、標準ライブラリのみ。Codexの作業環境で実行しました。通信先は127.0.0.1のみで、本番サイト・外部API・実データには接続していません。

架空のチケットA・Bと、担当Aの更新可能な利用者、担当Aの閲覧専用利用者、期限切れの利用者を用意しました。利用者・所属・操作権限・期限はサーバー内のデモトークン表から取得し、要求本文にある「利用者」「所属」「承認済み」は権限の根拠にしません。期限の判定には固定した論理時刻を使います。権限判定の障害は、状態を模擬的に切り替えたもので、実ネットワークの障害を起こしたものではありません。

期待結果と実測結果

条件期待HTTP実測HTTPデータ変更
担当内の閲覧200200なし
担当内の更新200200あり(担当内の更新のみ)
担当外の閲覧403403なし
担当外の更新403403なし
閲覧専用の更新403403なし
期限切れの更新401401なし
トークンなし401401なし
不明なトークン401401なし
利用者・承認済みの偽装403403なし
権限判定サービス障害503503なし
存在しない対象404404なし
許可しない操作400400なし
不正な更新データ400400なし

各ケースの前にデータを初期状態へ戻しました。担当内の更新ではチケットAの本文だけが変わり、閲覧では変更がなく、拒否・保留のケースでは両方のチケットが元のままであることを確認しました。また、成功時だけ対象データを返し、拒否時の応答に対象データが含まれないことも照合しました。失敗したテストは0件です。

結果から言えること・言えないこと

この実装と13ケースでは、入力文で利用者や承認済みを偽装しても、サーバー側の権限判定は変わりませんでした。権限サービスの障害状態でも、更新は実行されませんでした。一方、実際の認証基盤、トークン署名・失効、同時実行、再試行・二重更新、DBトランザクション、LLMによるツール選択、別経路からのアクセスは未検証です。本番へ適用する場合は、その環境の仕様に沿った追加検証が必要です。

再現方法とコード

以下のコードをauthorization_lab.pyとして保存し、Python 3.12で python authorization_lab.py を実行します。authorization-lab-results.jsonに期待結果・実測結果・実行日時・コードのSHA-256を記録します。デモトークンは説明用の識別子であり、本番用の認証方式ではありません。

掲載検証コードのSHA-256:35f0d6d2a5db10b3994ea3d26dd8d0f58ed4272cdfe5e3229281307e0dcf65e8

検証コード全文を表示
"""Local HTTP authorization demonstration. No LLM or production system involved."""
from copy import deepcopy
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
import hashlib
import json
from pathlib import Path
import platform
from threading import Thread
from urllib.error import HTTPError
from urllib.request import Request, urlopen

TOKENS = {
    "demo-editor": {"subject": "alice", "department": "A", "expires": 2000, "actions": ["read", "update"]},
    "demo-reader": {"subject": "bob", "department": "A", "expires": 2000, "actions": ["read"]},
    "demo-expired": {"subject": "alice", "department": "A", "expires": 999, "actions": ["read", "update"]},
}
NOW = 1000  # Fixed logical time; expiry behavior is reproducible.
INITIAL = {"ticket-A": {"department": "A", "text": "initial A"}, "ticket-B": {"department": "B", "text": "initial B"}}


class Handler(BaseHTTPRequestHandler):
    records = deepcopy(INITIAL)
    permission_state = "available"

    def log_message(self, *_):
        pass

    def do_POST(self):
        if self.path != "/operate":
            self.reply(404, "unknown_endpoint")
            return
        try:
            payload = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))))
            if not isinstance(payload, dict):
                raise ValueError("object required")
        except (ValueError, TypeError):
            self.reply(400, "invalid_json")
            return
        auth = self.headers.get("Authorization", "")
        principal = TOKENS.get(auth.removeprefix("Bearer ")) if auth.startswith("Bearer ") else None
        if principal is None or principal["expires"] <= NOW:
            self.reply(401, "invalid_or_expired_token")
            return
        if self.permission_state != "available":
            self.reply(503, "permission_check_unavailable")
            return
        action = payload.get("action")
        if action not in ("read", "update"):
            self.reply(400, "unsupported_action")
            return
        target = self.records.get(payload.get("target"))
        if target is None:
            self.reply(404, "unknown_target")
            return
        # Identity and scope come only from server-managed token/record data.
        if target["department"] != principal["department"] or action not in principal["actions"]:
            self.reply(403, "outside_scope")
            return
        if action == "update":
            if not isinstance(payload.get("text"), str):
                self.reply(400, "invalid_update")
                return
            target["text"] = payload["text"]
        self.reply(200, "allowed", target)

    def reply(self, code, reason, data=None):
        body = {"reason": reason}
        if data is not None:
            body["data"] = data
        raw = json.dumps(body).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)


CASES = [
    ("担当内の閲覧", "demo-editor", {"target": "ticket-A", "action": "read"}, "available", 200, False),
    ("担当内の更新", "demo-editor", {"target": "ticket-A", "action": "update", "text": "changed"}, "available", 200, True),
    ("担当外の閲覧", "demo-editor", {"target": "ticket-B", "action": "read"}, "available", 403, False),
    ("担当外の更新", "demo-editor", {"target": "ticket-B", "action": "update", "text": "changed"}, "available", 403, False),
    ("閲覧専用の更新", "demo-reader", {"target": "ticket-A", "action": "update", "text": "changed"}, "available", 403, False),
    ("期限切れの更新", "demo-expired", {"target": "ticket-A", "action": "update", "text": "changed"}, "available", 401, False),
    ("トークンなし", None, {"target": "ticket-A", "action": "read"}, "available", 401, False),
    ("不明なトークン", "demo-unknown", {"target": "ticket-A", "action": "read"}, "available", 401, False),
    ("利用者・承認済みの偽装", "demo-editor", {"target": "ticket-B", "action": "update", "text": "changed", "subject": "admin", "department": "B", "approved": True}, "available", 403, False),
    ("権限判定サービス障害", "demo-editor", {"target": "ticket-A", "action": "update", "text": "changed"}, "unavailable", 503, False),
    ("存在しない対象", "demo-editor", {"target": "ticket-X", "action": "read"}, "available", 404, False),
    ("許可しない操作", "demo-editor", {"target": "ticket-A", "action": "delete"}, "available", 400, False),
    ("不正な更新データ", "demo-editor", {"target": "ticket-A", "action": "update", "text": 12}, "available", 400, False),
]


def main():
    server = HTTPServer(("127.0.0.1", 0), Handler)
    worker = Thread(target=server.serve_forever, daemon=True)
    worker.start()
    results = []
    try:
        for name, token, payload, state, expected_status, expected_change in CASES:
            Handler.records = deepcopy(INITIAL)
            Handler.permission_state = state
            before = deepcopy(Handler.records)
            headers = {"Content-Type": "application/json"}
            if token:
                headers["Authorization"] = "Bearer " + token
            request = Request(f"http://127.0.0.1:{server.server_port}/operate", data=json.dumps(payload).encode(), headers=headers, method="POST")
            try:
                response = urlopen(request, timeout=3)
            except HTTPError as error:
                response = error
            with response:
                status = response.code
                body = json.loads(response.read())
                reason = body["reason"]
            changed = before != Handler.records
            expected_records = deepcopy(INITIAL)
            if expected_change:
                expected_records["ticket-A"]["text"] = "changed"
            output_ok = body.get("data") == expected_records["ticket-A"] if status == 200 else "data" not in body
            passed = status == expected_status and changed == expected_change and Handler.records == expected_records and output_ok
            results.append({"case": name, "expected_status": expected_status, "actual_status": status, "expected_change": expected_change, "actual_change": changed, "reason": reason, "passed": passed})
    finally:
        server.shutdown()
        server.server_close()
        worker.join()
    report = {"executed_at_utc": datetime.now(timezone.utc).isoformat(), "python": platform.python_version(), "platform": platform.system(), "source_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "scope": "localhost HTTP mock; fixed logical time; opaque demo tokens; no LLM, real IdP, database, external API, or production access", "passed": sum(r["passed"] for r in results), "total": len(results), "results": results}
    Path(__file__).with_name("authorization-lab-results.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    print(json.dumps(report, ensure_ascii=True))
    return 0 if report["passed"] == report["total"] else 1


if __name__ == "__main__":
    raise SystemExit(main())

制御の根拠となる考え方は、OWASP Authorization Cheat Sheetの最小権限、要求ごとの権限確認、認可失敗時の扱いを参照しています。上記のコードと実測結果は、このページの説明用に実施した独自の模擬検証です。

設計用語を詳しく確認する:ガードレール、Fail ClosedとFail Openの違い、自動化バイアスと人間の確認。

作業の前に確かめる12項目は、「AIを使う前の安全確認チェックリスト」に1枚の表でまとめています。社内資料にも使えます。

公式資料

問い合わせ対応AIは説明用の架空の設計例です。「模擬APIで確かめた権限制御」は、条件を限定して実際に行った検証です。実際の設計では、利用するサービスの仕様と、組織の規程・法令を確認してください。

辞典で関連分野を調べる

安全設計に関わる用語を、AI・LLM実務辞典の分野別ページでまとめて確認できます。