TT Lab
ブログ はじめる
学ぶ 学習パス コース

LLMアプリのセキュリティ — インジェクションから出力処理まで

モデルが選んだツールを誰の権限で実行するか — ロール・許可リスト・パス・確認

TT Labで続きを見る

目標

モデルが選んだツールをサービスの権限でそのまま実行するファイルアシスタントを、リクエストしたユーザーの役割・ツールの許可リスト・パス検証・人間の確認で縛ります。

なぜ重要か

エージェントのツールはアプリサーバーの権限で実行され、モデルはユーザーの言葉とドキュメント内の言葉を区別しません。そのため、viewer の頼みや共有ドキュメント内の文1つが削除を実行できてしまいます(混乱した代理人)。このラボの偽モデルはその弱点を常に見えるようにしたもので、本物のモデルは「ときどき」そうなるだけです — そのときどきをテストですべて見つけることはできないので、権限はモデルの外でかけます。採点ツールは採点のたびに新しいユーザー・ファイル・指示でアプリを起動し、ファイルが実際に削除・書き込み・読み取りされるかを見ます。正常なリクエスト(viewer の読み取り、editor の書き込み、admin の確認済みの削除)はそのまま通らなければなりません。

ステップ

  1. python3 /opt/fixtures/llmsec/kit.py agent で素材を展開してから、/root/llmsec/agent/app.py を127.0.0.1:8103で起動しておいてください(0.0.0.0 は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8103/health の seed が、展開時に受け取ったシードと同じでなければなりません。
  2. 過去の実行記録 /root/llmsec/agent/data/history.log から成功した(result=ok)実行だけを見て、役割(role)のポリシー表にないツールが実行された数と、ユーザーのホーム外のパス(.. でホームより上に出るか、絶対パス)に触れた数を、/root/llmsec/agent/review.txt に violations=<count>、escapes=<count> の2行で書いてください。ポリシー表は app.py の冒頭にあります。
  3. allowed を直して、ツールの実行をリクエストしたユーザーの役割で許可してください(ポリシー表どおり、表にないツールは誰にも許可しません)。拒否は data/audit.log に "event": "denied" として残します。viewer の読み取り、editor の書き込みはそのまま通らなければなりません。
  4. offered_tools を直して、モデルにそのユーザーの役割が使えるツールだけを見せてください。viewer の削除リクエストには削除の呼び出しがまったく出ず、誰のリクエストにも run_shell が出てはなりません。モデルが一覧にない名前をでっち上げても実行されず、アプリが落ちてはなりません。
  5. resolve を直して、パス引数を実際のパスに解決し、そのユーザーのホーム(data/files/<name>/)内のときだけ使うようにしてください。..・絶対パス・ホームの外を指すシンボリックリンクでは他人のファイルを読み書きできず、ホーム内のパス(sub/../sub/x.txt のようなものを含む)はそのまま通ります。
  6. run_call・handle_confirm を直して、delete_file はモデルが入れた confirm 引数に関係なく常に保留してください: レスポンスの actions に {"status": "pending", "id": <confirm-id>, ...} を出し、リクエストした本人が POST /confirm {"id": ...} を送ったときに一度だけ削除します。他のユーザーの確認・2回目の確認は 2xx 以外のコードで拒否し、他のユーザーによる確認の試みで保留が消えてはなりません。
  7. incident ls-agent-overreach を実行するとインシデントが発生します。3人のファイルが消えました(/srv/ls-agent/missing.txt)。人ごとに理由を見分け、アプリ /srv/ls-agent/app.py をラボのステップ3–5と同じように直し、誤って削除されたファイルだけをバックアップ /srv/ls-agent/backup/files/ から復元した後、/root/incident/ls-agent-overreach/report.txt に <owner>: <code> の3行と evidence: <log line> を書いてください。コードは model-confirm(文脈内の指示が確認を埋めた)、owner-request(所有者が自分で確認して削除した — インシデントではない)、path-escape(他人のホームに出たパス)、role-ignored(役割にない削除)、shell-tool(シェルツール)のいずれかです。

参考

アプリを展開してループバックで起動する

python3 /opt/fixtures/llmsec/kit.py agent で素材を展開してから、/root/llmsec/agent/app.py を127.0.0.1:8103で起動しておいてください(0.0.0.0 は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8103/health の seed が、展開時に受け取ったシードと同じでなければなりません。

方向: ラボのアプリを外に開く理由はありません — 待ち受けるアドレスがそのまま公開範囲です。 ツール: ss -ltnp, curl, setsid・nohup。 具体: 展開コマンドは上のとおりです。起動した後、ss -ltn で8103番が 127.0.0.1 として見えるか、/health の seed が /root/llmsec/agent/data/seed.txt と同じかを確認してください。

過去の実行から役割外・ホーム外を数える

過去の実行記録 /root/llmsec/agent/data/history.log から成功した(result=ok)実行だけを見て、役割(role)のポリシー表にないツールが実行された数と、ユーザーのホーム外のパス(.. でホームより上に出るか、絶対パス)に触れた数を、/root/llmsec/agent/review.txt に violations=<count>、escapes=<count> の2行で書いてください。ポリシー表は app.py の冒頭にあります。

方向: 直す前に、すでに何が起きていたかを数えます。ポリシーは「モデルが何を選んだか」ではなく「誰のリクエストだったか」で見ます。 ツール: awk, grep, python3 の os.path.normpath。 具体: パスは正規化してから判断してください — 途中に .. があっても、ホーム内に戻ってくるパスは外ではありません。失敗した(result=error)行は数えません。

リクエストしたユーザーの役割で許可する

allowed を直して、ツールの実行をリクエストしたユーザーの役割で許可してください(ポリシー表どおり、表にないツールは誰にも許可しません)。拒否は data/audit.log に "event": "denied" として残します。viewer の読み取り、editor の書き込みはそのまま通らなければなりません。

方向: モデルが選んだツール名は提案にすぎません。実行するかどうかは、リクエストした人の役割が決めます。 ツール: allowed(user, tool), users.json の role。 具体: ポリシー表をコードに移し、表にない役割・ツールは拒否する、閉じたデフォルトにしてください。

モデルに見せるツールを絞る

offered_tools を直して、モデルにそのユーザーの役割が使えるツールだけを見せてください。viewer の削除リクエストには削除の呼び出しがまったく出ず、誰のリクエストにも run_shell が出てはなりません。モデルが一覧にない名前をでっち上げても実行されず、アプリが落ちてはなりません。

方向: 選べないツールは、実行されることもありません。実行直前のチェック(ステップ3)と、見せる一覧の絞り込みは別々の層です。 ツール: offered_tools, TOOLS, allowed。 具体: 見せる一覧を役割で絞り込んでください。でっち上げた名前を実行側のチェックが拒否するかも確認します。

パスは実際のパスに解決してホーム内だけ

resolve を直して、パス引数を実際のパスに解決し、そのユーザーのホーム(data/files/<name>/)内のときだけ使うようにしてください。..・絶対パス・ホームの外を指すシンボリックリンクでは他人のファイルを読み書きできず、ホーム内のパス(sub/../sub/x.txt のようなものを含む)はそのまま通ります。

方向: 文字列でブロックすると、表記を変えたパスが通り抜けます。ファイルシステムが実際に指している場所を見ます。 ツール: os.path.realpath, os.path.commonpath, os.path.isabs。 具体: ホームと対象の両方を実際のパスに解決してから、共通の祖先がホームかどうかを確認してください。拒否を例外として投げれば、run_call が error として返します。

確認はモデルではなく人間から

run_call・handle_confirm を直して、delete_file はモデルが入れた confirm 引数に関係なく常に保留してください: レスポンスの actions に {"status": "pending", "id": <confirm-id>, ...} を出し、リクエストした本人が POST /confirm {"id": ...} を送ったときに一度だけ削除します。他のユーザーの確認・2回目の確認は 2xx 以外のコードで拒否し、他のユーザーによる確認の試みで保留が消えてはなりません。

方向: confirm: true はモデルが埋める引数なので、ドキュメント内の指示が入れられます。確認は、モデルが書き込めないチャネルで受けます。 ツール: PENDING, secrets.token_urlsafe, handle_confirm。 具体: 削除の呼び出しが来たら、実行する代わりに id を作って誰のものかと一緒に保管し、確認リクエストでは本人のものの場合だけ取り出して実行し、その後保留から消してください。確認の後も、役割・パスのチェックはもう一度通します。

インシデント — 3人のファイルが消えた

incident ls-agent-overreach を実行するとインシデントが発生します。ファイルアシスタント(本番版 /srv/ls-agent/app.py、データ /srv/ls-agent/data/)を使う3人のファイルが消えました(一覧 /srv/ls-agent/missing.txt、記録 data/audit.log・data/runs.log — runs.log にはリクエスト文、人間が送った確認(part=confirm)、一緒に渡された文脈、モデルが選んだ呼び出しが残ります)。人ごとに理由を見分け、/srv/ls-agent/app.py を直してください — ラボのステップ3–5の欠陥(役割・ツール一覧・パス)はインシデントの原因とは関係なくすべて塞ぎ、モデルが埋めた confirm 引数は見ません(admin が /run のリクエスト本文に "confirm": true を付けて自分のファイルを削除する正常な使い方はそのまま残します)。そして誤って削除されたファイルだけをバックアップ /srv/ls-agent/backup/files/ から同じ内容で復元します — 所有者が自分で確認して削除したものは復元しません。/root/incident/ls-agent-overreach/report.txt に <owner>: <code> の3行と evidence: <log line> を書きます。コードは人ごとに次のいずれかです: model-confirm(文脈内の指示が確認を埋めた)、owner-request(所有者が自分で確認して削除した — インシデントではない)、path-escape(他人のホームに出たパス)、role-ignored(役割にない削除)、shell-tool(シェルツール)。

方向: 「ファイルが消えた」は同じでも、誰が、どの文と文脈で、どのツールを呼び出したかは異なります。そして、すべての削除がインシデントというわけではありません。 ツール: grep, jq, audit.log の delete・shell, runs.log の part=request・confirm・context・call, users.json の role。 具体: 消えたパスごとに audit.log でそのファイルに触れた記録と rid を探し、runs.log でその rid のリクエスト文・文脈・呼び出しを見てください。リクエストした人が所有者か、その役割が削除できるか、確認が人間の送ったリクエストのフィールド(runs.log の part=confirm の行)から来たのか、モデルが文脈を見て埋めたツール引数から来たのかが分かれ目です。文脈の行に「確認」のような言葉があっても、人間の確認ではありません。