TT Lab
开始
学习 学习路径 课程

智能体删掉了我的数据库

复现并阻止智能体删除数据库的事故

在 TT Lab 中继续学习

目标

先复现只靠一个 run_sql 就能做任何事的 MCP 服务器原样执行 DROP TABLE 的情形,再把同一个服务器用三层措施修好——只读模式、白名单、破坏性工具的确认环节。

为什么重要

课程标题所说的事故并不是因为模型坏。收到“把测试数据清理一下”的模型,只是选了最短的路径 DROP TABLE,而服务器上没有任何装置能拦住它,这才是原因。规范把工具称为由模型选择(model-controlled)的,因此人必须能拒绝,服务器必须实现访问控制。把这些话写成代码,就是三件事。把工具拆窄并用名单开关;不需要写入的场合,连接本身就以只读方式打开;删除类工具没有确认就不会执行。本实验要亲手把这三样全部加上。

步骤

  1. 保存 /root/mcp/guard/seed.sql,并加载到 /root/mcp/guard/shop.db。customers 表要有 5 行,orders 表要有 8 行。
  2. 创建 /root/mcp/guard/server_v1.py。工具只有一个 run_sql(参数 sql),SELECT 返回结果行,其他 SQL 执行后返回 ok。数据库路径为环境变量 MCP_DB(默认 /root/mcp/guard/shop.db)。
  3. 用 /root/mcp/guard/attack.sh 复现事故。向 v1 服务器通过 run_sql 发送 DROP TABLE orders;,并把它的响应 JSON 和一行 tables_after=<남은 테이블 목록>(占位符为剩余的表列表)写入 /root/mcp/guard/incident.txt。orders 必须真的消失。
  4. 用 seed.sql 恢复数据库,并在 /root/mcp/guard/postmortem.md 中写下 cause=、missing_control=、fix= 三行(每行至少一句话)。
  5. /root/mcp/guard/server_v2.py——当环境变量 MCP_READ_ONLY=1 时,以只读方式打开数据库,这样即使通过 run_sql 发送 DROP,也会以 isError: true 被拒绝,表依然保留。SELECT 照常可用。
  6. /root/mcp/guard/server_v3.py 和 /root/mcp/guard/allowlist.json——去掉 run_sql,定义 list_customers、count_orders、delete_order 三个工具,但只有环境变量 MCP_ALLOWLIST(默认 allowlist.json)中列出的名称才会出现在 tools/list 中并接受调用。allowlist.json 里只放两个读取类工具。对名单之外的工具发起调用,返回 -32602。
  7. v3 的 delete_order(参数 id、confirm)在 confirm 不是 true 时什么都不删,而是以 isError: true 告知本来要删什么,只有 true 时才删除。
  8. 用 /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 表的行数)四行。

参考

创建商店数据库

保存 /root/mcp/guard/seed.sql,并加载到 /root/mcp/guard/shop.db。customers 表要有 5 行,orders 表要有 8 行。

sqlite3 可以用 sqlite3 shop.db < seed.sql 整个执行文件。用 Python 的话是 sqlite3.connect(...).executescript(open(...).read())。如果向已有的数据库再次加载,会报表已存在的错误,所以先把它删掉。

带有万能工具的服务器

创建 /root/mcp/guard/server_v1.py。工具只有一个 run_sql(参数 sql),SELECT 返回结果行,其他 SQL 执行后返回 ok。数据库路径为环境变量 MCP_DB(默认 /root/mcp/guard/shop.db)。

把上一个实验中服务器的工具换成唯一的 run_sql 即可。以 SELECT 开头就用 execute().fetchall(),否则在 executescript() 之后 commit()。sqlite 的错误要以 isError: true 返回。这个服务器是故意做得危险的——下一步会看到后果。

复现事故

用 /root/mcp/guard/attack.sh 复现事故。向 v1 服务器通过 run_sql 发送 DROP TABLE orders;,并把它的响应 JSON 和一行 tables_after=<남은 테이블 목록>(占位符为剩余的表列表)写入 /root/mcp/guard/incident.txt。orders 必须真的消失。

用 printf 构造两行请求(initialize、tools/call),通过管道传给 python3 server_v1.py,再连同输出一起,从 sqlite_master 读取剩余的表名并追加一行。响应里没有 isError 而是返回 ok——这就是服务器毫无抵抗地删掉了表的证据——把它留在文件里。

恢复并写下事故记录

用 seed.sql 恢复数据库,并在 /root/mcp/guard/postmortem.md 中写下 cause=、missing_control=、fix= 三行(每行至少一句话)。

恢复方法与第 1 步相同(删除文件后重新加载)。事故记录回答三个问题——原因是什么(工具设计),缺少了哪些装置(白名单、只读、确认),要改什么。

只读模式

/root/mcp/guard/server_v2.py——当环境变量 MCP_READ_ONLY=1 时,以只读方式打开数据库,这样即使通过 run_sql 发送 DROP,也会以 isError: true 被拒绝,表依然保留。SELECT 照常可用。

用 sqlite3.connect(f"file:{DB}?mode=ro", uri=True) 打开,写入语句就会因 sqlite 错误而失败。捕获这个异常,以 isError: true 文本返回。检查字符串里有没有 DROP 的做法,要跟进的变形没有尽头,所以选择直接拦截连接本身。

用白名单收窄

/root/mcp/guard/server_v3.py 和 /root/mcp/guard/allowlist.json——去掉 run_sql,定义 list_customers、count_orders、delete_order 三个工具,但只有环境变量 MCP_ALLOWLIST(默认 allowlist.json)中列出的名称才会出现在 tools/list 中并接受调用。allowlist.json 里只放两个读取类工具。对名单之外的工具发起调用,返回 -32602。

把工具定义的全集(ALL_TOOLS)和读取名单文件后过滤得到的 TOOLS 区分开。tools/call 中不在 TOOLS 里的名称也要按未知工具回答。名单文件不存在或已损坏时就是空集合——不提供任何工具才是安全的默认值。

删除类工具需要确认

v3 的 delete_order(参数 id、confirm)在 confirm 不是 true 时什么都不删,而是以 isError: true 告知本来要删什么,只有 true 时才删除。

用 args.get("confirm") is True 比较。拒绝时,把本来要删除的行(状态、金额)写进文本,给人提供判断依据;删除成功时返回 rowcount 即可。评分器会把临时副本数据库通过 MCP_DB 传入来测试,并通过 MCP_ALLOWLIST 提供一个列有 delete_order 的临时 allowlist。

证明同样的攻击被挡住了

用 /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 表的行数)四行。

复制 attack.sh 并改成 v3,再多加一行不带 confirm 调用 delete_order 的请求。响应中 run_sql 应是 -32602,delete_order 应出现 isError。行数用 SELECT COUNT(*) FROM orders 统计后如实写下。