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

エージェントが私のDBを消した

エージェントによるDB削除事故を再現して防ぐ

TT Labで続きを見る

目標

run_sql1つで何でもできるMCPサーバーがDROP TABLEをそのまま実行してしまう事故を再現したあと、同じサーバーを3層で直します。読み取り専用モード、許可リスト、破壊的ツールの確認ステップです。

なぜ重要なのか

題名の事故は、モデルが悪くて起きるものではありません。「テストデータを整理して」という言葉を受け取ったモデルが、最も短い道であるDROP TABLEを選んだだけで、それを防ぐ仕組みがサーバーに1つもなかったことが原因です。仕様は、ツールをモデルが選ぶもの(model-controlled)と呼び、そのため人が拒否できなければならず、サーバーはアクセス制御を実装しなければならないと書いています。その文をコードに移すと、3つになります。ツールを狭く分けてリストで有効・無効にする、書き込みが要らない場面では接続そのものを読み取り専用で開く、削除するツールは確認なしには動かさない。このラボでは、3つをすべて手で入れます。

ステップ

  1. シードSQL(/root/mcp/guard/seed.sql)を保存し、DB(/root/mcp/guard/shop.db)へ読み込んでください。customersが5行、ordersが8行になっている必要があります。
  2. サーバーのv1(/root/mcp/guard/server_v1.py)を作成してください。ツールはrun_sql(引数はsql)1つだけで、SELECTは結果の行を返し、それ以外のSQLは実行したあとにokを返します。DBのパスは環境変数MCP_DB(デフォルトは/root/mcp/guard/shop.db)です。
  3. 攻撃スクリプト(/root/mcp/guard/attack.sh)で事故を再現してください。v1サーバーにrun_sqlでDROP TABLE orders;を送り、そのレスポンスJSONと、tables_after=<남은 테이블 목록>(プレースホルダーは残ったテーブルの一覧です)の1行を、ファイル(/root/mcp/guard/incident.txt)に残します。ordersが本当に消えている必要があります。
  4. DBをseed.sqlで復旧し、事故記録のファイル(/root/mcp/guard/postmortem.md)に、cause=、missing_control=、fix=の3行(それぞれ1文以上)を書いてください。
  5. サーバーのv2(/root/mcp/guard/server_v2.py): 環境変数MCP_READ_ONLY=1のときはDBを読み取り専用で開き、run_sqlでDROPを送ってもisError: trueで拒否されて、テーブルが残る必要があります。SELECTはそのまま動きます。
  6. サーバーのv3(/root/mcp/guard/server_v3.py)と許可リスト(/root/mcp/guard/allowlist.json): run_sqlをなくし、list_customers・count_orders・delete_orderの3つのツールを定義します。ただし、環境変数MCP_ALLOWLIST(デフォルトはallowlist.json)に書かれた名前だけをtools/listに返し、呼び出しを受け付けてください。allowlist.jsonには読み取りツール2つだけを入れてください。リスト外のツールの呼び出しは-32602です。
  7. v3のdelete_order(引数はidとconfirm)は、confirmがtrueでなければ何も削除せず、isError: trueで何を削除しようとしたのかを知らせ、trueのときだけ削除します。
  8. v3用の攻撃スクリプト(/root/mcp/guard/attack_v3.sh)で同じ攻撃をv3に送り、防がれることを確認してください。そして、レポートのファイル(/root/mcp/guard/guard-report.txt)に、run_sql_removed=yes、readonly_blocks_drop=yes、delete_requires_confirm=yes、orders_rows=<현재 orders 행 수>(プレースホルダーは現在のordersの行数です)の4行を書いてください。

参考

店のDBを作る

シードSQL(/root/mcp/guard/seed.sql)を保存し、DB(/root/mcp/guard/shop.db)へ読み込んでください。customersが5行、ordersが8行になっている必要があります。

sqlite3では、sqlite3 shop.db < seed.sqlでファイルをまるごと実行します。Pythonで行うなら、sqlite3.connect(...).executescript(open(...).read())です。すでにあるDBに再度読み込むと、テーブルが存在するというエラーになるので、先に削除してください。

万能ツールがあるサーバー

サーバーのv1(/root/mcp/guard/server_v1.py)を作成してください。ツールはrun_sql(引数はsql)1つだけで、SELECTは結果の行を返し、それ以外のSQLは実行したあとにokを返します。DBのパスは環境変数MCP_DB(デフォルトは/root/mcp/guard/shop.db)です。

前のラボのサーバーで、ツールをrun_sql1つに変えれば足ります。SELECTで始まるならexecute().fetchall()、そうでなければexecutescript()のあとにcommit()します。sqliteのエラーはisError: trueで返してください。このサーバーは、わざと危険なままにしておきます。次のステップで、その結果を見ます。

事故を再現する

攻撃スクリプト(/root/mcp/guard/attack.sh)で事故を再現してください。v1サーバーにrun_sqlでDROP TABLE orders;を送り、そのレスポンスJSONと、tables_after=<남은 테이블 목록>(プレースホルダーは残ったテーブルの一覧です)の1行を、ファイル(/root/mcp/guard/incident.txt)に残します。ordersが本当に消えている必要があります。

printfでリクエスト2行(initialize、tools/call)を作ってpython3 server_v1.pyにパイプし、出力と一緒に、残ったテーブルの名前をsqlite_masterから読んで1行付け足します。レスポンスにisErrorがなくokが返ってくること(サーバーが何の抵抗もなく削除したという証拠)を、ファイルに残してください。

復旧して事故記録を書く

DBをseed.sqlで復旧し、事故記録のファイル(/root/mcp/guard/postmortem.md)に、cause=、missing_control=、fix=の3行(それぞれ1文以上)を書いてください。

復旧はステップ1と同じです(ファイルを削除して再度読み込みます)。事故記録は、3つの問いに答えます。何が原因だったのか(ツールの設計)、どの仕組みがなかったのか(許可リスト・読み取り専用・確認)、何を変えるのか。

読み取り専用モード

サーバーのv2(/root/mcp/guard/server_v2.py): 環境変数MCP_READ_ONLY=1のときはDBを読み取り専用で開き、run_sqlでDROPを送ってもisError: trueで拒否されて、テーブルが残る必要があります。SELECTはそのまま動きます。

sqlite3.connect(f"file:{DB}?mode=ro", uri=True)で開くと、書き込みの文はsqliteのエラーで失敗します。その例外を捕まえて、isError: trueのテキストとして返してください。文字列にDROPがあるかを検査する方式は、追いかけるべき変形に際限がないので、接続そのものを塞ぐ方式を選びます。

許可リストで絞る

サーバーのv3(/root/mcp/guard/server_v3.py)と許可リスト(/root/mcp/guard/allowlist.json): run_sqlをなくし、list_customers・count_orders・delete_orderの3つのツールを定義します。ただし、環境変数MCP_ALLOWLIST(デフォルトはallowlist.json)に書かれた名前だけをtools/listに返し、呼び出しを受け付けてください。allowlist.jsonには読み取りツール2つだけを入れてください。リスト外のツールの呼び出しは-32602です。

ツール定義の全体(ALL_TOOLS)と、リストのファイルを読んで絞り込んだTOOLSを分けてください。tools/callでも、TOOLSにない名前は未知のツールとして返答します。リストのファイルがない、または壊れているときは空の集合にします。どのツールも返さないのが、安全なデフォルトです。

削除するツールには確認を求める

v3のdelete_order(引数はidとconfirm)は、confirmがtrueでなければ何も削除せず、isError: trueで何を削除しようとしたのかを知らせ、trueのときだけ削除します。

args.get("confirm") is Trueで比較してください。拒否するときは、削除しようとした行(状態・金額)をテキストに含めて、人が判断する材料を渡し、削除したときはrowcountを返せば足ります。採点ツールは、一時コピーのDBをMCP_DBで渡して試し、リストにdelete_orderを入れた一時的なallowlistをMCP_ALLOWLISTで渡します。

同じ攻撃が防がれることを証明する

v3用の攻撃スクリプト(/root/mcp/guard/attack_v3.sh)で同じ攻撃をv3に送り、防がれることを確認してください。そして、レポートのファイル(/root/mcp/guard/guard-report.txt)に、run_sql_removed=yes、readonly_blocks_drop=yes、delete_requires_confirm=yes、orders_rows=<현재 orders 행 수>(プレースホルダーは現在のordersの行数です)の4行を書いてください。

attack.shをコピーしてv3用に書き換え、delete_orderをconfirmなしで呼ぶリクエストを1行追加してください。レスポンスで、run_sqlは-32602、delete_orderはisErrorになっている必要があります。行数はSELECT COUNT(*) FROM ordersで数えて、そのまま書きます。