エージェントによるDB削除事故を再現して防ぐ
目標
run_sql1つで何でもできるMCPサーバーがDROP TABLEをそのまま実行してしまう事故を再現したあと、同じサーバーを3層で直します。読み取り専用モード、許可リスト、破壊的ツールの確認ステップです。
なぜ重要なのか
題名の事故は、モデルが悪くて起きるものではありません。「テストデータを整理して」という言葉を受け取ったモデルが、最も短い道であるDROP TABLEを選んだだけで、それを防ぐ仕組みがサーバーに1つもなかったことが原因です。仕様は、ツールをモデルが選ぶもの(model-controlled)と呼び、そのため人が拒否できなければならず、サーバーはアクセス制御を実装しなければならないと書いています。その文をコードに移すと、3つになります。ツールを狭く分けてリストで有効・無効にする、書き込みが要らない場面では接続そのものを読み取り専用で開く、削除するツールは確認なしには動かさない。このラボでは、3つをすべて手で入れます。
ステップ
- シードSQL(
/root/mcp/guard/seed.sql)を保存し、DB(/root/mcp/guard/shop.db)へ読み込んでください。customersが5行、ordersが8行になっている必要があります。 - サーバーのv1(
/root/mcp/guard/server_v1.py)を作成してください。ツールはrun_sql(引数はsql)1つだけで、SELECTは結果の行を返し、それ以外のSQLは実行したあとにokを返します。DBのパスは環境変数MCP_DB(デフォルトは/root/mcp/guard/shop.db)です。 - 攻撃スクリプト(
/root/mcp/guard/attack.sh)で事故を再現してください。v1サーバーにrun_sqlでDROP TABLE orders;を送り、そのレスポンスJSONと、tables_after=<남은 테이블 목록>(プレースホルダーは残ったテーブルの一覧です)の1行を、ファイル(/root/mcp/guard/incident.txt)に残します。ordersが本当に消えている必要があります。 - DBをseed.sqlで復旧し、事故記録のファイル(
/root/mcp/guard/postmortem.md)に、cause=、missing_control=、fix=の3行(それぞれ1文以上)を書いてください。 - サーバーのv2(
/root/mcp/guard/server_v2.py): 環境変数MCP_READ_ONLY=1のときはDBを読み取り専用で開き、run_sqlでDROPを送ってもisError: trueで拒否されて、テーブルが残る必要があります。SELECTはそのまま動きます。 - サーバーの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です。 - v3の
delete_order(引数はidとconfirm)は、confirmがtrueでなければ何も削除せず、isError: trueで何を削除しようとしたのかを知らせ、trueのときだけ削除します。 - 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行を書いてください。
参考
- 読み取り専用の接続:
sqlite3.connect(f"file:{경로}?mode=ro", uri=True)(プレースホルダーはパスです)。書き込みを試みると、sqliteがattempt to write a readonly databaseとして拒否します。SQL文字列を検査して防ぐ方式は、追いかけるべき変形が際限なく増えるので、エンジンで防いでください。 - 採点ツールは、破壊的ツールを試すときに、学生のDBを一時コピーにして
MCP_DBで渡します。サーバーがその環境変数を読まないと、採点が実際のDBを削除したり、失敗したりします。 - 許可リストは「ある中から有効にするもの」ではなく、「リストになければ存在しないツール」です。
tools/listにも出てこず、tools/callも未知のツールとして返答する必要があります。 - よくある間違い1: confirmを文字列の
"true"で受け入れてしまうことです。スキーマがbooleanなら、is Trueで比較してください。 - よくある間違い2: リストがないときにすべてのツールを開いてしまうことです。ないときは何も開かない方が、閉じたデフォルトです。
店の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で数えて、そのまま書きます。