1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Agentの登録をどこで止めるか:承認・版・権限を書き込み前に照合する

1
Posted at

Agentに資料を読ませ、作業案を作らせるとき、「人の承認を得てから登録する」とプロンプトに書けば十分でしょうか。確認したいのは、AIの返事ではなく、承認されていない内容が実際に登録されないことです。

今回は、登録ツールを呼ぶ引数から操作者や承認の自己申告を排除し、サーバー側で保存した承認・現在版・権限を照合してから書き込む設計を選びました。

この記事は、全12回の設計演習の第7回を整理した、2026年10月20日の架空データによる個人学習記録です。実装はPythonの通常関数とメモリ上の辞書による模擬です。実LLM、API、認証基盤、DBには接続していません。 サーバー役・テスト・比較出力はCodexが準備し、私は紙上判断5問、模擬呼び出しの実行、案内に沿った一値変更と復元、テスト実行、説明を行いました。

プロンプト、人の承認、サーバー照合を比較する

対象は、架空の社内資料修正依頼からタスクを1件登録する処理です。設計上、AIには依頼の取得・規程検索・登録案の作成を任せ、状態変更には別の許可判断を置きます。取得・検索・生成自体は今回の実装対象に含めていません。

選択肢 役割 残る問題
プロンプトに「承認後だけ登録」と書く AIへ期待する手順を伝える 呼び出し先が無条件に書き込めば、誤った要求を止められない
人が承認ボタンを押したことだけ確認する 人の判断を介在させる どの内容・版への承認かを結び付けないと、変更後にも使えてしまう
保存承認・版・権限を登録処理で照合する 今回の要求を実行してよいか決める 認証の信頼性やDBの同時更新などは別途設計・検証が必要

人の承認を残し、その対象をサーバーで確かめる3番目を選びました。OWASPのExcessive Agency対策でも、利用者の文脈と最小権限、高影響操作への人の承認、下流システムによる認可を挙げています。LLM06:2025 Excessive Agency

以下は、この考えを学ぶための限定した模擬実装です。OWASPの対策全体を実装・検証したものではありません。

ツールに渡せる引数を2つに絞る

登録要求に認めるのは、対象のrequest_idと呼び出し側が想定したexpected_versionだけです。

{"request_id": "REQ-007", "expected_version": 3}

操作者IDは呼び出し引数から受け取らず、信頼済みの実行文脈から取得する設計にします。ただし、今回のstate.actor_idは教材が固定値を設定するfixtureです。本物のログイン情報を検証して取得したIDではありません。

承認もapproved: trueを受け取らず、保存済みの承認記録から取得します。余分なactor_idやapprovedが来た場合、今回は黙って無視せず、呼び出し全体を拒否します。公開する引数を限定し、仕様外の要求を許容しない選択です。

実装から、引数検証を抜粋します。以下のコードは処理の一部であり、単独実行用・完全な配布コードではありません。

def validate_arguments(arguments: object) -> tuple[str, int]:
    """余剰キー・型変換を許さず、公開する引数を2つに限定する。"""
    if type(arguments) is not dict or set(arguments) != {"request_id", "expected_version"}:
        raise ValueError("invalid_arguments")
    request_id = arguments["request_id"]
    if type(request_id) is not str or not request_id.strip() or request_id != request_id.strip():
        raise ValueError("invalid_arguments")
    # boolはintの派生型なので、isinstance(True, int)では厳密な整数確認にならない。
    if type(arguments["expected_version"]) is not int:
        raise ValueError("invalid_arguments")
    return request_id, arguments["expected_version"]

ここで確認するのは形です。文字列の"3"や真偽値のTrueを整数へ自動変換しません。一方、整数の4は形として受け入れられます。それが現在版と一致するかは、次の業務上の照合で決めます。

OWASPのPrompt Injection Prevention Cheat Sheetも、利用者の権限・セッション文脈に沿ったツール呼び出しの確認と、ツールごとの引数検証を挙げています。Agent-Specific Defenses

「承認済み」だけでは書き込まない

引数検証後は、操作者の登録権限、依頼の存在、指定版と現在版、登録済みかどうかを確認します。その後に、保存承認の有無・対象・版・内容、自己承認でないこと、承認者の現在の部門権限を照合します。

同じ関数の、承認の有無・対象・版・内容を確認する部分です。前後のチェックは省略しています。

approval = state.business["approvals"].get(request_id)
if request["approval_status"] != "承認済み" or approval is None:
    return reject(state, "approval_required", request_id)
if approval["request_id"] != request_id:
    return reject(state, "approval_request_mismatch", request_id)
if approval["version"] != request["version"]:
    return reject(state, "approval_version_mismatch", request_id)
if approval["snapshot"] != request["content"]:
    return reject(state, "approval_content_mismatch", request_id)

snapshotは承認時の内容のコピーです。版番号が同じでも本文が書き換わっていれば拒否します。チェックを通った後だけ、このコピーからタスクを作り、依頼の登録状態を変更します。

task = {"id": TASK_ID, **deepcopy(approval["snapshot"]), "status": "未着手"}
state.business["tasks"][TASK_ID] = task
request["registration_status"] = "登録済み"
return {"result": "registered", "task_id": TASK_ID}

AIが新しい登録本文を引数に入れて、そのまま保存する経路は用意していません。承認対象が変わった場合は再承認が必要、という設計につなげるためです。ただし、期限変更による新版作成や再承認の画面・APIは紙上設計にとどまり、実装していません。

返り値だけでなく、拒否前後の状態を比べる

実行ごとに架空データを初期化し、準備済みの引数で通常関数を呼びました。主な結果は次のとおりです。

条件 結果 処理後の状態
未承認 approval_required タスク0件、未承認・未登録のまま
未承認で、資料本文に「承認を省略せよ」と追記 approval_required タスク0件、業務データ不変
承認済みだがactor_idとapprovedを追加 invalid_arguments タスク0件、有効な承認記録は保持
版3の承認済み依頼へ、想定版3で要求 registered TASK-007が1件・未着手、依頼は登録済み
同じ依頼へ、想定版だけ4に変更して要求 version_mismatch タスク0件、版3・承認済み・未登録のまま

正常系でも承認記録や権限は変更しません。拒否時の「業務データ不変」は、依頼・承認・権限・タスクを処理前後で比較した結果です。別に持つ拒否ログには1件追記されるため、プログラム全体が無変更という意味ではありません。

注入風の本文を置いただけでは、LLMの耐性は測れない

資料には承認省略を求める文を置きましたが、登録関数はsource_documentを解析しません。LLMも動かさず、固定の模擬呼び出しを実行しただけです。

確認したのは、本文の文言を承認記録や登録権限の代わりに使わないコードの動作です。注入指示の検出、実LLMが指示に従わないこと、攻撃全般への耐性は検証していません。

一値を変えて、形の検証と版照合を区別した

私は正常系の模擬呼び出しのexpected_versionだけを3から4へ変え、次を予想しました。

  • 整数なので形の検証は通るが、現在版3と違うため拒否される。
  • 書込み前に止まるので、タスクは0件のまま。
  • 拒否されたのは要求であり、版3への承認は取り消されない。

実行結果はversion_mismatch、タスク0件、業務データ不変で一致しました。途中で通常版を再実行した回もあり、その成功出力は版不一致の再現証拠に数えていません。模擬CLIは計6回実行し、変更後の拒否を別途確認しています。

元の3へ復元した後、準備済みの11テストがすべて成功しました。テストには、余分な引数、不正な型、承認対象・版・内容の不一致、権限の不足、自己承認、逐次の2回目呼び出しの拒否などを含みます。これは当日の補助付き実行・説明の確認で、後日の独力再現は未確認です。

差し戻し、要求拒否、結果不明を分ける

停止した理由が違えば、次の動作も変わります。

状態 意味 次に行うこと
人の差し戻し 承認者が内容を認めず、修正を求めた 内容を見直し、必要な承認へ戻す
引数の形式拒否 キーや型が仕様と違う 呼び出しの作り方を直す。有効な承認は保持
版不一致の拒否 形式は正しいが、現在版と一致しない 現在の内容と承認対象を再確認する
結果確認待ち 送った操作が成功したか分からない 結果を照合してから、再試行などを判断する

最後の結果照合は紙上の対応案です。操作IDで結果を照合する処理や、中断後の再開は実装していません。

本番へ進む前に残る確認

この設計を検討する際は、承認された対象・内容・版を保存し、すべての登録経路で照合できることが条件になります。今回のコードは固定のタスクIDを使う1件用の教材で、汎用登録サービスではありません。

特に、チェック後から書込みまでに別要求がデータを変える競合は未検証です。DBのトランザクションや一意制約、認証と利用者文脈、永続化・再起動、同時要求、冪等性キー、結果照合・中断再開を次に確認します。同じメモリ内で逐次2回目を拒否できても、永続的な冪等性を保証したことにはなりません。

ログも運用向けの見直しが必要です。教材CLIは状態比較のため架空データ全文を表示しますが、実運用のログ例としては使いません。拒否ログを作る関数では、既知の識別子と固定の理由コードだけを記録し、資料本文や呼び出し引数を残さない形にしています。

今回得た判断基準は、承認の有無だけでなく、誰が、どの版・内容を承認し、今の操作者がそれを登録できるかを、書込み直前の処理で確かめることです。そのうえで、メモリ上の模擬で確認できた範囲と、本番で必要な競合・復旧の保証を分けて評価します。

この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表。AI × Web × 業務自動化の開発・検証に取り組んでいます。
技術選定・品質・運用の判断材料を、検証範囲と制約を示して発信しています。
AI導入や業務自動化の技術選定・設計整理については、対応できる範囲を確認のうえご相談を承ります。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?