在智能体删除数据库之前——允许列表、只读模式与确认
一句话总结
让破坏性工具变得安全的机制有三层。去掉万能工具,把窄工具按白名单开关;不需要写入的场合,连接本身就以只读方式打开;删除类工具没有确认就不会执行。规范里“人必须能够拒绝”这句话变成代码,就是这三样。
为什么需要它
事故的原料总是一样的。为了方便而做出来的一个 run_sql。让模型自己写 SQL,就不用做十个工具,演示也好得惊人。然后某一天,一句“把上个月的测试订单清理一下”变成了 DROP TABLE orders。模型只是选了最短的路,而服务器没有在这条路上设任何一道门。
规范概览的 “Security and Trust & Safety” 一节正面谈到了这种情况。工具就是任意代码执行,必须以相应的谨慎对待;工具的说明或注解(annotations)除非来自可信服务器,否则不应轻信;宿主在调用工具之前必须获得用户的明确同意。工具规范更具体——必须始终有一个人能够拒绝工具调用的环节(SHOULD),服务器必须验证所有输入、实现访问控制并限制调用频率(MUST)。规范自己写明了它无法在协议层强制这些,所以实现的人不加,就哪里都没有。
工作原理
第一,把工具收窄。不用 run_sql,而是像 count_orders、list_customers、delete_order 这样,一个意图对应一个工具。窄工具的参数 schema 就窄,schema 窄,模型能构造出的请求空间就小。delete_order 的参数只有一个 id,所以“删除表的方法”根本不存在。这是访问控制的第一层,比任何其他检查都可靠——因为没有需要检查的东西。
第二,用白名单开启。服务器把已定义的全部工具(ALL_TOOLS)和当前要开启的工具(allowlist.json)区分开。tools/list 只给出名单中的工具,tools/call 对名单之外的名称也按未知工具回答(-32602)。名单文件不存在或已损坏时,不开启任何工具——这是关闭式默认值。在生产环境中,只开读取类工具,删除类工具只在需要做相应工作的时间段开启。必须是“不在名单里就等于没有这个工具”,而不是“从现有工具里挑出要关的”,这样新增工具时才不会被误打开。
第三,只读要在连接上设置。检查 SQL 字符串是否以 SELECT 开头的做法,需要检查的东西会无穷无尽——把多条语句连在一起的输入、注释和大小写变形、以 WITH 开头的读取语句(SQLite 还允许 WITH ... DELETE),都要一个个跟进,漏掉一个就是事故。正确的做法是改用 Python 标准库 sqlite3 所支持的 URI,加上 mode=ro 来打开。
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
con.executescript("DROP TABLE orders;") # sqlite3.OperationalError: attempt to write a readonly database
写入会被数据库引擎拒绝,所以无论怎样包装字符串都行不通,服务器代码里也根本没有需要检查的名单。服务器只要捕获那个异常,以 isError: true 返回即可。用一个环境变量(MCP_READ_ONLY=1)来开启,这样在只需要查询的部署中,写入路径根本不存在。
第四,破坏性工具要获得确认。delete_order 只在 confirm 参数为布尔值 true 时才删除。否则不删除,并把“如果删了,会有什么消失”(订单号、状态、金额)作为 isError: true 文本返回。宿主把这段文本展示给人,人批准后,模型再以 confirm: true 重新调用。规范所说的 “human in the loop” 就是这一来一回。不能接受字符串 "true"——schema 是 boolean 的话,就用 is True 比较。工具定义的 annotations 里可以附上 readOnlyHint、destructiveHint 之类的提示,但规范要求客户端除非来自可信服务器,否则不要信任这些注解(MUST)。提示只是用于界面显示,不是安全装置。
在现场相遇的样子
事故后的恢复要靠备份。但更重要的是事故记录——原因是什么(工具设计),缺少了哪些装置(白名单、只读、确认),要改什么。没有这三行,下一个服务器也会从 run_sql 开始。本模块的实验之所以按事故复现 → 恢复 → 记录 → 三层装置的顺序进行,原因就在这里。
还有一点。加入确认环节之后,会冒出“智能体每次都要问,太慢了”的抱怨。答案不是去掉确认,而是把工具进一步收窄。如果是 cancel_test_order(只针对测试订单,只改状态)这样的工具,而不是 delete_order,那就安全到不需要确认。安全来自工具能做的事情的大小,而不是确认弹窗的数量。
下一项实验要做什么
用 run_sql 服务器真的发送 DROP TABLE orders,亲眼看到 orders 消失,然后恢复,并写下事故记录。之后依次加入只读模式、白名单和确认参数,证明同样的攻击会在三处被挡住。