0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI エージェントのツール権限設計:ユーザー権限での実行、タスク単位のスコープ、操作に結び付けた承認

0
Posted at

AI エージェントにツールを渡すとき、「どのツールを渡すか」と同じくらい重要なのが「誰の権限で、どの範囲まで、どんな確認を経て実行するか」です。プロンプトインジェクションを確実に防ぐ方法はまだないので、モデルがだまされたときに届く範囲は、権限の設計でしか決められません。

この記事では、ツール権限の設計を 3 つの層に分けて扱います。

  1. 誰として動くか:共有のサービスアカウントではなく、依頼したユーザーの権限で動かす
  2. どの範囲まで動けるか:ユーザー権限のうち、そのタスクに必要な分だけに絞り、対象のリソースごとに確かめる
  3. どんな確認を経るか:取り消せない操作には、操作そのものに結び付けた、期限付き・1 回限りの承認を求める

最後に、この 3 層を標準ライブラリだけの Python で実装し、注入された指示による呼び出しや承認の流用が止まることを確かめます。

「ツールごとに必要なスコープを決めて、ユーザーの権限で判定する」という基本形は OWASP Top 10 for LLM 2026 の記事 で、セッション単位で危険な組み合わせを避ける考え方は 信頼境界と権限分離の記事 で扱いました。ここではその先の、ID・トークン・承認の作り方に絞ります。

なぜ「誰の権限で動くか」が問題になるのか

OWASP が 2025 年 12 月に公開した「Top 10 for Agentic Applications 2026」は、ASI02 に Tool Misuse and Exploitation(正規のツールの誤用)、ASI03 に Identity and Privilege Abuse(ID と権限の悪用)を置いています。ASI03 の例として挙げられているのは、権限の低いエージェントの依頼を権限の高いエージェントがそのまま実行してしまうケースや、以前の処理で使った管理者の認証情報を、別のユーザーの依頼に使い回してしまうケースです。

どちらも、典型的な confused deputy(混乱した代理人) の問題です。代理人(エージェント)が自分の強い権限で動くと、依頼者が本来できないことまで代理人経由でできてしまいます。

よくある構成を比べると、違いがはっきりします。

構成 エージェントの権限 注入された指示で届く範囲 ログに残る主体
共有のサービスアカウント 全ユーザー分のデータに届く サービス全体 エージェントだけ
ユーザーのトークンをそのまま転送 そのユーザーの全権限 ユーザーができること全部 ユーザーだけ
ユーザー権限をタスク単位に絞った委任トークン ユーザー権限 ∩ タスクに必要な分 そのタスクの範囲 ユーザーとエージェントの両方

1 行目は「モデルがだまされたら全ユーザーのデータが漏れる」構成です。2 行目は 1 行目よりよいものの、メールを要約するだけのタスクに、送信や削除までできるトークンを渡すことになります。また、ログ上ではユーザー本人の操作とエージェントの操作が区別できません。目指すのは 3 行目です。

層 1:ユーザーの権限で、エージェントとして動く

ユーザーの権限で動かすといっても、エージェントがユーザーに「なりすます」のとは違います。OAuth 2.0 Token Exchange(RFC 8693、2020 年 1 月)は、この 2 つを区別しています。

  • なりすまし(impersonation):発行されたトークンはユーザー本人のものと区別がつかない
  • 委任(delegation):トークンの主体(sub)はユーザーのまま、実際に動いている者を act(actor)クレームで示す

エージェントには委任の形が向いています。API 側は sub でアクセスできるデータを判断し、act で「エージェント経由の操作か」を判断できます。たとえば「エージェント経由では、ユーザー本人なら可能な権限変更を受け付けない」というポリシーを API 側に置けます。監査ログにも両方を残せます。

この方向は標準化の議論でも進んでいます。NIST の NCCoE は 2026 年 2 月 5 日に、ソフトウェアエージェントと AI エージェントの識別・認可に関するコンセプトペーパーを公開し、エージェントの認証、データやツールへのアクセス制御、監査と否認防止、プロンプトインジェクションへの対策を論点に挙げて意見を募りました(意見募集は 4 月 2 日まで)。IETF でも、AI エージェントがユーザーの代理で動くための OAuth 拡張がいくつかインターネットドラフトとして出ていますが、いずれも標準化前の段階です。

実装上の注意は 2 つあります。

  • 受け取ったトークンを下流にそのまま転送しない。 MCP のセキュリティベストプラクティスは、MCP サーバーが自分宛てに発行されていないトークンを受け取ることを禁止し、トークンの「素通し(token passthrough)」を明確にアンチパターンとしています。下流の API には、そのサービス宛て(audience を絞った)のトークンを改めて取得して使います
  • キャッシュした認証情報をユーザー間で共有しない。 ASI03 の例そのものです。トークンの保存場所はユーザーとセッションに結び付けます

層 2:タスク単位でスコープを絞り、対象リソースごとに確かめる

スコープは「ユーザー権限 ∩ タスクに必要な分」

ユーザーが tickets:read tickets:write mail:send を持っていても、「自分のチケットを整理して」というタスクにメール送信は要りません。エージェントに渡すトークンは、タスクを始めるときに必要な分だけに絞って、短い有効期限で発行します。

MCP のセキュリティベストプラクティスでも、最初は読み取り中心の最小限のスコープで始め、特権的な操作が必要になった時点で追加のスコープを要求する(step-up)段階的なモデルが勧められています。* や full-access のような包括的なスコープや、将来の確認を減らすために無関係な権限をまとめて要求することは、よくある誤りとして挙げられています。

スコープだけでは足りない:対象を見て判定する

tickets:read を持っていることは、「どのチケットでも読める」ことを意味しません。同じテナントの他人のチケットや、他テナントのチケットを読めてしまうのは、Web API でおなじみのオブジェクト単位の認可(BOLA / IDOR)の不備です。エージェントでは、モデルが引数に入れた ID をそのまま信じると、この不備がそのまま攻撃面になります。

判定に使う「誰が」は、モデルが渡した引数ではなく、検証済みトークンの sub から取ります。モデルに user_id を引数として書かせる設計は、注入された指示で書き換えられます。MCP の仕様も、サーバーが発行した状態のハンドル(カート ID など)を持っていることを認証の代わりにしてはならず、ユーザー ID は検証済みのトークンから取るべきだとしています。

層 3:承認は「操作そのもの」に結び付ける

取り消せない操作(送信、削除、支払い、権限変更)には人の承認を入れます。ここでよくある誤りは、承認を「approved=True というフラグ」で表すことです。OWASP の AI Agent Security Cheat Sheet も、単純な確認フラグでは不十分だとして、実行する部品の側で次の点を確かめるよう求めています。

  • 承認が、いまの実行者と、まさに実行しようとしているツール呼び出しに対するものであること
  • 承認がまだ有効で、使われていないこと
  • 確認と使用済みにする処理を、実行の直前にまとめて行うこと
  • 対象や引数が変わったら、新しい承認を取ること

フラグ方式だと、「T-1 を閉じてよいか」への承認のあとで、モデルが(注入された指示などで)引数を T-4 に変えても通ってしまいます。また、承認をモデルの出力で表すと、モデル自身が承認を作れてしまいます。承認は、モデルが書けない経路(アプリの UI でユーザーが押したボタン → 承認サービス)で発行し、操作の中身に署名して渡します。

承認を求める操作の選び方

Cheat Sheet は、ツールをリスクで分ける例として、検索や読み取りは承認なし、ファイル書き込みやメール送信・コード実行、削除や送金は承認が必要、登録されていないツールは高リスクとして扱う、という対応を示しています。同時に、リスク区分は実行の許可ではなく、承認が要らない操作でも実行者の権限は別に確かめる必要がある、とも書いています。

承認の回数が多すぎると、ユーザーは中身を見ずに押すようになります。承認を「取り消せない」「外部に出る」「権限が変わる」操作に絞り、承認画面には実行する操作と引数をそのまま(宛先、件数、金額など)表示するのが基本です。

コードで確かめる:委任トークン・リソース単位の認可・操作に結び付けた承認

ここまでの 3 層を、ツールを実行する直前のゲートとして実装します。

  • 実行環境: Python 3.13.16(標準ライブラリのみ)
  • LLM は呼び出していません。モデルが返したツール呼び出し(ツール名と引数)は固定値で与えています
  • チケットやユーザーはすべて自作のテストデータです
  • トークンは JWT などではなく、検証済みのクレームを表すデータクラスで代用しています
import hashlib
import hmac
import json
import secrets
import time
from dataclasses import dataclass

# --- 検証用のデータ(自作) ---
TICKETS = {
    "T-1": {"tenant": "acme", "assignee": "alice", "title": "請求書の再発行"},
    "T-2": {"tenant": "acme", "assignee": "bob", "title": "人事評価の相談"},
    "T-3": {"tenant": "globex", "assignee": "carol", "title": "他社のチケット"},
    "T-4": {"tenant": "acme", "assignee": "alice", "title": "アカウント削除の依頼"},
}
USERS = {
    "alice": {"tenant": "acme", "perms": {"tickets:read", "tickets:write", "mail:send"}},
}

# ツールごとに必要な権限とリスク区分をコード側で決める(未登録は拒否)
TOOLS = {
    "read_ticket":  {"perm": "tickets:read",  "risk": "low"},
    "close_ticket": {"perm": "tickets:write", "risk": "high"},
    "send_mail":    {"perm": "mail:send",     "risk": "high"},
}


class Denied(Exception):
    pass


@dataclass(frozen=True)
class Token:
    """エージェントに渡すトークン。sub=依頼したユーザー、act=代理で動くエージェント"""
    sub: str
    act: str
    scopes: frozenset
    exp: float


def issue_task_token(user: str, agent: str, task_scopes: set, ttl=300) -> Token:
    """タスク単位で絞ったトークンを発行する(ユーザー権限 ∩ タスクの必要分)"""
    granted = USERS[user]["perms"] & task_scopes
    return Token(sub=user, act=agent, scopes=frozenset(granted), exp=time.time() + ttl)


# --- 承認:実行する操作そのものに結び付け、期限付き・1 回限りにする ---
APPROVAL_KEY = secrets.token_bytes(32)   # 承認サービスだけが持つ鍵
USED_NONCES = set()


def canonical(actor: str, tool: str, args: dict) -> bytes:
    return json.dumps({"actor": actor, "tool": tool, "args": args},
                      sort_keys=True, ensure_ascii=False).encode()


def approve(actor: str, tool: str, args: dict, ttl=120) -> dict:
    """承認画面でユーザーが「実行」を押したときに、承認サービスが発行する"""
    nonce = secrets.token_hex(8)
    exp = time.time() + ttl
    msg = canonical(actor, tool, args) + f"|{nonce}|{exp}".encode()
    return {"nonce": nonce, "exp": exp,
            "sig": hmac.new(APPROVAL_KEY, msg, hashlib.sha256).hexdigest()}


def verify_approval(actor, tool, args, ap):
    if ap is None:
        raise Denied("承認が必要")
    msg = canonical(actor, tool, args) + f"|{ap['nonce']}|{ap['exp']}".encode()
    expected = hmac.new(APPROVAL_KEY, msg, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, ap["sig"]):
        raise Denied("承認と操作が一致しない")
    if time.time() > ap["exp"]:
        raise Denied("承認の期限切れ")
    if ap["nonce"] in USED_NONCES:
        raise Denied("承認は使用済み")
    USED_NONCES.add(ap["nonce"])


# --- 実行ゲート:モデルの外で、トークンと対象リソースを見て判定する ---
AUDIT = []


def execute(token: Token, tool: str, args: dict, approval=None) -> str:
    try:
        rule = TOOLS.get(tool)
        if rule is None:
            raise Denied(f"未登録のツール {tool}")
        if time.time() > token.exp:
            raise Denied("トークンの期限切れ")
        if rule["perm"] not in token.scopes:
            raise Denied(f"スコープ不足 {rule['perm']}")
        if tool in ("read_ticket", "close_ticket"):
            t = TICKETS.get(args["ticket_id"])
            # 対象の持ち主はモデルの引数ではなく、トークンの sub から判断する
            if t is None or t["tenant"] != USERS[token.sub]["tenant"] \
                    or t["assignee"] != token.sub:
                raise Denied("このユーザーは対象にアクセスできない")
        if rule["risk"] == "high":
            verify_approval(token.sub, tool, args, approval)
        result = "OK"
    except Denied as e:
        result = f"NG: {e}"
    AUDIT.append({"sub": token.sub, "act": token.act, "tool": tool,
                  "args": args, "result": result})
    return result


# ===== 検証 =====
# モデルが返したツール呼び出しは固定値で与える
agent = "support-agent"

print("== 1. タスク単位のトークン(チケット対応だけ)")
tok = issue_task_token("alice", agent, {"tickets:read", "tickets:write"})
print("   scopes:", sorted(tok.scopes))
print("   read_ticket T-1           ->", execute(tok, "read_ticket", {"ticket_id": "T-1"}))
print("   read_ticket T-2 (bob の)  ->", execute(tok, "read_ticket", {"ticket_id": "T-2"}))
print("   read_ticket T-3 (他社)    ->", execute(tok, "read_ticket", {"ticket_id": "T-3"}))
print("   send_mail                 ->", execute(tok, "send_mail", {"to": "x@attacker.example"}))
print("   export_all                ->", execute(tok, "export_all", {}))

print("== 2. 承認を操作に結び付ける")
args = {"ticket_id": "T-1"}
print("   承認なし                   ->", execute(tok, "close_ticket", args))
ap = approve("alice", "close_ticket", args)
print("   別チケットに流用           ->", execute(tok, "close_ticket", {"ticket_id": "T-4"}, ap))
print("   承認どおり                 ->", execute(tok, "close_ticket", args, ap))
print("   同じ承認で再実行           ->", execute(tok, "close_ticket", args, ap))
old = approve("alice", "close_ticket", args, ttl=-1)
print("   期限切れの承認             ->", execute(tok, "close_ticket", args, old))

print("== 3. 監査ログ(sub と act の両方を残す)")
for row in AUDIT[-4:-2]:
    print("  ", json.dumps(row, ensure_ascii=False))

実行結果です。

== 1. タスク単位のトークン(チケット対応だけ)
   scopes: ['tickets:read', 'tickets:write']
   read_ticket T-1           -> OK
   read_ticket T-2 (bob の)  -> NG: このユーザーは対象にアクセスできない
   read_ticket T-3 (他社)    -> NG: このユーザーは対象にアクセスできない
   send_mail                 -> NG: スコープ不足 mail:send
   export_all                -> NG: 未登録のツール export_all
== 2. 承認を操作に結び付ける
   承認なし                   -> NG: 承認が必要
   別チケットに流用           -> NG: 承認と操作が一致しない
   承認どおり                 -> OK
   同じ承認で再実行           -> NG: 承認は使用済み
   期限切れの承認             -> NG: 承認の期限切れ
== 3. 監査ログ(sub と act の両方を残す)
   {"sub": "alice", "act": "support-agent", "tool": "close_ticket", "args": {"ticket_id": "T-4"}, "result": "NG: 承認と操作が一致しない"}
   {"sub": "alice", "act": "support-agent", "tool": "close_ticket", "args": {"ticket_id": "T-1"}, "result": "OK"}

結果から読み取れること

1 のスコープには mail:send が入っていません。 alice 本人はメールを送れますが、チケット対応のタスクではトークンを発行する時点で外しています。注入された指示でモデルが send_mail を呼んでも、承認の画面まで進まずに止まります。登録されていない export_all も拒否されます。

同じテナントの bob のチケット(T-2)も読めません。 スコープは足りていますが、対象を見ると alice の担当ではありません。判定に使っているのはトークンの sub で、モデルが渡した引数ではありません。

2 では、T-1 への承認を T-4 に使い回せません。 承認の署名は実行者・ツール・正規化した引数を含めて計算しているので、引数が 1 つ変わるだけで一致しなくなります。T-4 も alice のチケットなので、スコープとリソースの判定は通っており、止めているのは承認の検証です。同じ承認での 2 回目の実行と、期限切れの承認も拒否されます。

3 の監査ログには sub と act の両方が残ります。 「alice が直接閉じた」のか「alice の依頼で support-agent が閉じた」のかを後から区別できます。拒否された呼び出しも残すので、注入の試みの調査に使えます。

この実装で守れないこと

  • ユーザー本人の権限内の誤操作。 委任トークンは、ユーザーができないことを防ぐ仕組みです。alice 自身のチケットを、注入された指示どおりに閉じてしまうことは、承認を挟まない限り止まりません
  • 承認画面の内容が正しく伝わらないこと。 承認は表示された内容への同意です。引数が長い本文や添付ファイルだと、人は全部を確かめられません。本文の要約をモデルに作らせて画面に出すと、その要約自体が汚染される可能性があります
  • プロセス内の状態。 使用済みの nonce をメモリに持っているので、複数台で動かす場合は共有ストアで「確認して使用済みにする」処理を原子的に行う必要があります
  • トークンの発行側。 委任トークンの発行(認可サーバー、Token Exchange のエンドポイント)は実装していません。実際には既存の ID 基盤の機能を使い、エージェントのクライアントごとに発行できるスコープの上限を設定します

設計を見直すときの順番

既存のエージェントの権限を見直すときは、次の順で確かめると、影響の大きいところから手を付けられます。

  1. エージェントが使っている認証情報を洗い出す。 共有のサービスアカウントや、管理者の API キーで動いているツールがあれば、最初にユーザー権限での実行に切り替えます。全ユーザーのデータに届く構成は、モデルが 1 回だまされただけで全体の事故になります
  2. タスクごとに必要なスコープを書き出す。 「チケット対応」「メールの要約」のようにタスクを分け、それぞれの上限を決めます。タスクの途中で足りなくなったら、その時点でユーザーに追加を求めます
  3. ツールの中で対象を確かめる。 スコープの確認だけで済ませていないか、モデルの引数からユーザーを決めていないかを見ます
  4. 取り消せない操作の承認を、操作に結び付ける。 フラグ方式の承認を、実行者・ツール・引数・期限・1 回限りを含むものに置き換えます
  5. ログに sub と act を残す。 事故のあとで「誰の依頼で、どのエージェントが、何をしたか」を追えるようにします

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?