复现并阻止智能体删除数据库的事故
目标
先复现只靠一个 run_sql 就能做任何事的 MCP 服务器原样执行 DROP TABLE 的情形,再把同一个服务器用三层措施修好——只读模式、白名单、破坏性工具的确认环节。
为什么重要
课程标题所说的事故并不是因为模型坏。收到“把测试数据清理一下”的模型,只是选了最短的路径 DROP TABLE,而服务器上没有任何装置能拦住它,这才是原因。规范把工具称为由模型选择(model-controlled)的,因此人必须能拒绝,服务器必须实现访问控制。把这些话写成代码,就是三件事。把工具拆窄并用名单开关;不需要写入的场合,连接本身就以只读方式打开;删除类工具没有确认就不会执行。本实验要亲手把这三样全部加上。
步骤
- 保存
/root/mcp/guard/seed.sql,并加载到/root/mcp/guard/shop.db。customers 表要有 5 行,orders 表要有 8 行。 - 创建
/root/mcp/guard/server_v1.py。工具只有一个run_sql(参数sql),SELECT 返回结果行,其他 SQL 执行后返回ok。数据库路径为环境变量MCP_DB(默认/root/mcp/guard/shop.db)。 - 用
/root/mcp/guard/attack.sh复现事故。向 v1 服务器通过run_sql发送DROP TABLE orders;,并把它的响应 JSON 和一行tables_after=<남은 테이블 목록>(占位符为剩余的表列表)写入/root/mcp/guard/incident.txt。orders 必须真的消失。 - 用 seed.sql 恢复数据库,并在
/root/mcp/guard/postmortem.md中写下cause=、missing_control=、fix=三行(每行至少一句话)。 /root/mcp/guard/server_v2.py——当环境变量MCP_READ_ONLY=1时,以只读方式打开数据库,这样即使通过run_sql发送DROP,也会以isError: true被拒绝,表依然保留。SELECT 照常可用。/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。- v3 的
delete_order(参数id、confirm)在confirm不是true时什么都不删,而是以isError: true告知本来要删什么,只有true时才删除。 - 用
/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 表的行数)四行。
参考
- 只读连接:
sqlite3.connect(f"file:{경로}?mode=ro", uri=True)(占位符为路径)。尝试写入时,sqlite 会以attempt to write a readonly database拒绝——靠检查 SQL 字符串来拦截的做法,需要跟进的变形会无穷无尽,所以请在引擎层面拦截。 - 评分器测试破坏性工具时,会把学员的数据库复制成临时副本,并通过
MCP_DB传入。如果服务器不读取这个环境变量,评分就会删掉真实的数据库,或者失败。 - 白名单不是“从现有工具中挑出要开的”,而是“不在名单里就等于没有这个工具”。它既不能出现在
tools/list中,tools/call也必须按未知工具回答。 - 常见错误 1:把 confirm 当作字符串
"true"接受。schema 是 boolean 的话,要用is True比较。 - 常见错误 2:没有名单时打开所有工具。没有名单时什么都不开,才是关闭式默认值。
创建商店数据库
保存 /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 统计后如实写下。