邮件 Agent 防注入:先隔离行动权限
文章分析邮件中的提示注入风险,并介绍一种让读取邮件的模型不持有工具、无法发送邮件的设计。其重点是通过权限隔离限制不可信输入触发外部操作。
AI 深度解读
邮件 Agent 最危险的地方,是陌生人能把指令混进它必须阅读的内容里。这篇文章给出的解法很直接:读邮件的模型没有工具,只能输出固定格式的数据。即使被提示注入骗了,它也只能写错一张卡片,无法转发邮件或删除内容。
作者转述了一个本地邮箱助手的实验,包括 37 封样本邮件、七项代码检查和三种模型规模的攻击测试。值得借鉴的是它如何限制错误的后果;需要打问号的是,小样本成绩和静态扫描,究竟能证明多少安全性。
模型会被骗,先决定它被骗后能做什么
项目把邮件阅读器限制为一个分类组件:模型不能发送、转发、删除邮件,也不能调用任何工具。输出只有一个 JSON 对象,包含类别、优先级、邮件要求的行动、一句话摘要和可疑标记。
读懂不可信内容的权限,不该自动附带执行其中要求的权限。
提示注入(Prompt Injection)就是把操纵模型的指令塞进待处理内容。邮件尤其适合这种攻击:攻击者既能写给人看的钓鱼话术,也能藏一段专门骗摘要助手的指令。如果阅读器手里有转发工具,一次理解错误就可能变成实际操作;没有工具,损害范围便缩到输出字段。
项目还把适合代码的工作交还给代码。群发邮件按邮件头识别,不经过模型。日期也由程序计算,因为早期小模型曾把「周五之前」算到周六。改进后,每次请求先列出接下来几天的日期,让模型复制,而非自己推算。
规则也要针对实际运行的模型调整。他们在同一个 9B,即 90 亿参数模型上,对比了三个版本的规则文件,修正预约在几个月后仍算任务、门户里有待查看消息也算任务等判断。物流通知和感谢信属于信息,活动日期不能直接当截止日期;摘要最多 20 词,早期规则却放出了 37 词的摘要。
这些不是模型升级,而是把业务边界写清楚。原文没有给出三个版本的完整成绩,无法判断每次修改带来多少提升,但迭代方法很明确:拿有标准答案的邮件测,看它具体错在哪里。
七项代码检查为什么能补上模型的盲区
模型通常只看到处理后的正文,攻击痕迹却可能藏在邮件结构里。项目因此增加七项不调用模型的检查:
- 检查 HTML 中读者看不到的隐藏文本。
- 检查零宽字符和文字方向控制字符。
- 检查「给助手的说明」「忽略先前指令」等面向 AI 的话术。
- 检查邮件身份认证失败的结果。
- 检查包含组织名称、实际却不属于该组织的近似域名。
- 检查可信联系人显示名与实际发件地址是否匹配。
- 检查回复地址与发件地址是否属于不同域名。
在 37 封样本里,模型单独标出了 6 次植入攻击中的 3 次。漏掉的攻击中,有两次存在输入盲区:一封发票的指令不可见,另一封针对 AI 助手的通讯邮件,已经被邮件头规则提前过滤,根本没进入模型。
七项检查合起来标出了全部 6 次攻击,对另外 31 封邮件没有误报。这个成绩支持「检查应覆盖原始结构」的设计,但不能推导出生产环境也会零误报。
他们又构造了 6 个更有针对性的攻击,让每个模型分别在完整规则、删除两句防御提示的条件下处理。4B 模型被骗 3 次,自身一次也没标出可疑;27B 模型被骗 1 次。一个伪造正文结束标记、随后冒充用户发言的攻击,在所有运行中骗过了全部三个模型。
防御提示不是保证。架构才限制了这次失败的后果:「转发整段会话」最多变成页面上的文字。
原文还提供了静态扫描脚本,用于在 CI 中查找 Python 项目里的发送能力;这段脚本由文章作者编写,并非原项目代码:
#!/usr/bin/env python3
"""Prove a project cannot send: fail on any transport import or send call."""
import ast, sys, pathlib
BANNED_IMPORTS = {"smtplib", "email.mime.smtp"}
BANNED_CALLS = {"send", "sendmail", "send_message", "send_mail"}
def scan(path):
tree = ast.parse(path.read_text(encoding="utf-8"))
imported = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imported.add(node.module.split(".")[0])
calls = {n.func.attr for n in ast.walk(tree)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)}
return imported & BANNED_IMPORTS, calls & BANNED_CALLS
def check(target):
me = pathlib.Path(__file__).resolve()
files = [p for p in sorted(pathlib.Path(target).rglob("*.py"))
if p.resolve() != me]
bad = 0
for p in files:
try:
imp, calls = scan(p)
except (SyntaxError, UnicodeDecodeError) as e:
print(" [skip] %s: %s" % (p, e))
continue
if imp or calls:
bad += 1
print(" FAIL %s imports=%s calls=%s" % (p, sorted(imp), sorted(calls)))
if not files:
print(" (no .py files found under %s)" % target)
print("checked %d file(s), %d with send capability" % (len(files), bad))
return 1 if bad else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: egress_check.py <dir> | --self")
sys.exit(2)
sys.exit(check(sys.argv[1]))
重点看 return 1 if bad else 0:命中返回 1,通过返回 0;参数数量错误返回 2。作者称已跑过这三条路径。但「通过」只代表没有命中这些规则,并不等于无法发送。脚本还会跳过解析失败的文件,没有找到 Python 文件时也返回 0。
代写回复为什么必须先让用户给答案
阅读器没有工具,风险仍可能出现在另一个组件:替用户起草回复的模型。它即使不能发送,也能在草稿里擅自承诺。
第一版规则要求,不得同意用户没有明确授权的金额、日期或会议,8 份测试草稿全部违反要求。其中一份甚至安排了家庭旅行、蛋糕和请假。第二版要求把待决定事项留在方括号里,8 份中有 6 份用了括号,但全部仍在括号旁擅自做了决定。
有效的调整是改变任务:起草前,程序先让用户用几句话给出答案,模型只负责把答案写成回复。此后 4 份测试草稿都没有超出用户答案。程序还会列出构成承诺的句子,供发送前检查。
让模型组织语言之前,先把决定交给用户。
运行成本也有参考价值。在 M4 Max 上,9B 模型处理进入模型的 28 封样本耗时 36 至 43 秒;27B 模型约需 1 分半,类别判断答对 33 项中的 32 项。原文没有解释 28 封与 33 项的统计口径差异。
另一个结果更适合拿来复测:27B 模型关闭推理步骤后,得分完全相同,速度却快了 6 倍。这里的「每封邮件不花钱」指本地推理没有逐封 API 费用,并未计入硬件、电力和维护成本。
我的判断
我认同它把权限边界放在提示词前面。相比继续叠加防御提示,移除阅读组件的工具能直接限制后果;七项代码检查则补上正文模型看不到的信息,两者解决的问题不同。
但测试范围必须讲清。37 封邮件中的零误报、4 份草稿全部符合要求,都是小样本结果。它们值得继续验证,尚不足以作为普遍可靠性的承诺。文章也没有提供与其他防护方案的完整对照。
静态扫描尤其不能当作「无法发送」的证明。除了作者承认的依赖和跨语言盲区,这段脚本只匹配特定导入名和属性调用,识别不了任意 HTTP 发送路径;导入名又被截成顶层模块,无法匹配黑名单里的 email.mime.smtp 完整字符串。--self 也只出现在帮助文字中,没有对应分支。
它适合作为防止已知发送代码混入的 CI 检查。真正限制发送,还要靠工具暴露范围、凭据权限和运行环境。固定 JSON 也需要后续组件正确处理:如果下游自动执行「请求行动」字段里的文字,风险依然会沿着链路传下去。
能拿来做什么
- 做邮件、工单或外部文档摘要时,把阅读模型配置成只输出固定字段的组件,并检查下游是否会把字段内容当成执行指令。
- 给本地模型维护一组带标准答案的样本,把日期、任务类别、摘要长度和攻击邮件一起纳入回归验证,再比较规则与推理开关。
- 做回复草稿功能时,先收集用户明确的答复,再让模型润色,并把承诺句单独展示给用户检查。
- 将「没有发送能力」拆成可核查的工程条件:检查工具、凭据、依赖和网络权限,静态扫描只承担其中能覆盖的一部分。