04 - 防线在哪一层:网关、客户端,还是模型
前置:01 - 威胁模型、03 - 工具投毒与 Rug Pull。 读过 Agent 网关 · 05 - MCP 网关 会更顺,本篇会大量回引。
本篇回答:知道了会被怎么打,那该在哪儿设防?每道防 线各能拦住什么、拦不住什么?
一、一次工具调用经过的六道关卡
先把链路铺开,防线的位置就清楚了:
六个位置,每一个都能设防,但能力和代价完全不同。图里这几个词先解释一下:
| 词 | 意思 |
|---|---|
| 提示注入 | 攻击者把指令伪装成普通数据喂给模型。对模型来说系统提示词和一封邮件的正文处在同一个上下文里,地位相同,所以它分不清哪句是命令,详见 01 篇 |
| 工具白名单 | 事先规定这次会话只允许调用哪几个工具。模型即使被诱导去调别的,网关这一层直接拒绝 |
| 参数校验 | 比白名单更细一层:不只看调了哪个工具,还看参数长什么样。比如允许调 send_email 但收件人域名必须是公司内部 |
| 沙箱 | 一个受限的执行环境,Agent 在里面跑代码伤不到宿主机。注意它限制的是破坏能力,不是信息搬运能力 |
| Lethal Trifecta | 「致命三要素」:能接触私有数据 + 摄入不可信内容 + 有对外通道。三者同时具备时数据外泄就无法从原理上避免,详见 01 篇 |
二、逐道防线的能力边界
② 模型自身:必要,但不能当防线
模型经过对齐训练,会拒绝明显有害的请求。这有用,但不能作为安全边界,原因在 01 篇说过:指令和数据在同一个上下文里,攻击者总能找到新的表达方式。
判断标准很简单:一个防线如果无法给出确定性的保证,它就是缓解措施,不是边界。 模型的拒绝能力属于前者。
④ 客户端人工审批:最强,但最贵
Claude Code 那类工具用的是这条路 —— 危险操作弹窗给人确认。
这是唯一能挡住"完全没见过的新型攻击"的防线,因为判断的是人。
它的三个代价也很实在:
- 审批疲劳。弹得太频繁,人就开始无脑点"同意" —— 这正是 01 篇里 ASI09(利用人对 Agent 的信任)的核心。
- 只能保护交互式场景。跑在后台的批处理 Agent 没人可问。
- 人必须看得懂。弹窗里如果是一串 base64,点同意的人什么也没审。
闭源,怎么研究? anthropics/claude-code(★141,929)仓库里只有 plugins 和 examples,没有 CLI 源码。可以走的路是第三方对它配置面的解析 —— snyk/agent-scan 里有个 agents/claude_code.py(20 KB),专门读取和检查 Claude Code 的权限配置。用扫描器反推被扫描对象的权限模型,是拆闭源工具的一条实用路径。
我自己在 Lobster0 里做的是"权限分层 + 参数绑定审批":不是每次调用都问,而是首次审批时把工具和一组具体参数绑定,之后同样的组合免审、参数变了重新问。这是在审批疲劳和安全之间找平衡。
⑤ 沙箱:兜底,管不了数据外泄
沙箱能挡住 ASI05(意外的代码执行),但挡不住 Lethal Trifecta。
一个跑在完美沙箱里的 Agent,只要它能读私有数据、能上网,照样能把数据传出去 —— 沙箱限制的是它对宿主机的破坏,不是它对信息的搬运。
①③⑥ 网关:唯一同时看得见"谁"和"要干什么"的位置
这是本篇的核心论点。
| 位置 | 知道调用者是谁吗 | 知道要调什么工具吗 | 能拦吗 |
|---|---|---|---|
| 模型 | ❌ | ✅ | ⚠️ 不确定 |
| 客户端 | ✅ | ✅ | ✅ 但要人 |
| 沙箱 | ❌ | ⚠️ 只见系统调用 | ✅ 但只管进程 |
| 网关 | ✅ | ✅ | ✅ 确定性 |
只有网关同时握着身份和意图,而且判断是确定性的、可审计的、不需要人在场。
三、工具权限护栏逐行拆解
LiteLLM 的 tool_permission 护栏是这类实现里最完整的一个:litellm/proxy/guardrails/guardrail_hooks/tool_permission.py,927 行。
先看官方给的示例配置(litellm/proxy/example_config_yaml/tool_permission_example.yaml,全文 37 行):
guardrails:
- guardrail_name: "tool-permission-guardrail"
litellm_params:
guardrail: tool_permission
mode: "post_call"
default_on: true
violation_message_template: "this violates our org policy, we don't support executing {tool_name} commands"
rules:
- id: "allow_bash"
tool_name: "Bash"
decision: "allow"
- id: "allow_github_mcp"
tool_name: "mcp__github_*"
decision: "allow"
- id: "allow_aws_documentation"
tool_name: "mcp__aws-documentation_*_documentation"
decision: "allow"
- id: "deny_read_commands"
tool_name: "Read"
decision: "Deny"
default_action: "deny"
on_disallowed_action: "block"
四个设计点。
设计点 1:mode: "post_call" —— 拦的是模型的决定
护栏跑在模型返回之后、工具执行之前。它检查的不是用户输入,是模型输出里的 tool_calls。
这个位置选得对。因为攻击的效果体现在"模型决定调用什么工具、传什么参数",而不是用户说了什么。你不需要判断输入里有没有注入 —— 你只需要判断模型的决定合不合规。
💡 这是整篇最值得记住的一句:放弃检测攻击,改为检测后果。 攻击的形态无穷无尽,而"哪些工具带哪些参数是允许的"是一个有限、可枚举、可审计的集合。
设计点 2:默认拒绝
def __init__(
self,
default_action: Literal["deny", "allow"] = "deny",
代码签名里的默认值就是 deny,配置里也再写一遍 default_action: "deny"。
白名单模式。 新接入一个 MCP Server,它的工具默认全部不可用,必须显式放行 —— 这正好是 03 篇里 Rug Pull 的解药:即使某个 server 在更新里偷偷新增了工具,那些新工具因为不在白名单里,天然被拦。
设计点 3:通配符匹配 MCP 工具
tool_name: "mcp__github_*"
还记得 Agent 网关 · 05 篇里 Envoy AI Gateway 那个 nameSeparator = "__" 吗?工具名被拼成 backend__tool 的形式。
规则里的 mcp__github_* 正是在利用这个命名约定 —— 一条规则放行一整个后端的工具。命名空间前缀在这里从"避免重名"升级成了"权限的挂载点"。
设计点 4:参数级匹配 —— 本篇技术含量最高的一段
只匹配工具名是不够的。delete_file 这个工具,删 /tmp/cache 和删 /etc/passwd 是两回事。
_collect_argument_paths 把工具调用的参数递归展开成一组"路径 → 值":
def _collect_argument_paths(
self, value: Any, current_path: str, collected: dict[str, list[Any]], depth: int = 0,
) -> None:
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
if depth > DEFAULT_MAX_RECURSE_DEPTH:
return
if isinstance(value, dict):
for key, sub_value in value.items():
next_path = f"{current_path}.{key}" if current_path else key
self._collect_argument_paths(sub_value, next_path, collected, depth + 1)
elif isinstance(value, list):
list_path: Final = f"{current_path}[]" if current_path else "[]"
for item in value:
self._collect_argument_paths(item, list_path, collected, depth + 1)
else:
if not current_path:
return
collected.setdefault(current_path, []).append(value)
嵌套对象走 a.b.c,数组统一折叠成 a[] —— 数组里每一个元素都要单独接受检查,不能因为第一个合规就放行整个数组。
然后逐条比对:
for path, compiled_pattern in compiled_patterns.items():
values = path_value_map.get(path)
if not values:
return (
False,
f"Missing value for path '{path}' required by rule '{rule.id}'",
)
for raw_value in values:
if not compiled_pattern.fullmatch(str(raw_value)):
return (
False,
f"Value '{raw_value}' for path '{path}' does not match allowed pattern"
f" '{compiled_pattern.pattern}' for tool '{tool_name or 'unknown_tool'}'",
)
三个细节都是安全代码的正确写法:
| 细节 | 为什么重要 |
|---|---|
fullmatch 而不是 search | search 只要字符串里有一段匹配就算过 —— /tmp/../etc/passwd 会被 ^/tmp/ 的规则放行 |
| 路径缺值直接判不通过 | 规则要求了某个参数,请求里没有 → 拒绝,而不是"没有就不检查" |
| 递归有深度上限 | 防止攻击者用深层嵌套的 JSON 打爆解析 |
还有一处工程细节,在规则加载那里:
# missing compiled target is read as a match-all wildcard).
没编译出模式的规则被当作通配全匹配 —— 这是个需要警惕的默认值。它对可用性友好,但意味着一条写错的规则可能变成"放行一切"。配了参数级规则之后,一定要实际测一条应该被拒的请求,确认它真的被拒了。
四、流式响应带来的困难
上面讲的都是工具调用。但还有一类内容需要拦:模型输出的文本本身。
难点在流式:你在第 10 个 chunk 才发现这段输出里有敏感信息,而前 9 个 chunk 已经发给用户了。
agentgateway 为此单独写了一个模块 —— crates/agentgateway/src/llm/policy/streaming_guardrails.rs(830 行),并在策略里给了一个专门的开关:
pub struct PromptGuard {
#[serde(default, skip_serializing_if = "PromptGuardStreamingMode::is_disabled")]
pub streaming: PromptGuardStreamingMode,
流式护栏本质上是在延迟和安全之间选:
| 策略 | 延迟 | 安全 |
|---|---|---|
| 不检查流式 | 最好 | 无保护 |
| 攒够 N 个 token 检查一次 | 增加 N 个 token 的延迟 | 有窗口期 |
| 全部攒完再检查 | 退化成非流式 | 完整 |
没有免费的选项。 这也是为什么很多网关的流式护栏默认是关的。
五、纵深防御:每层拦什么
单独任何一层都不够。合理的分工:
| 层 | 拦什么 | 拦不住什么 |
|---|---|---|
| 网关入站 | 身份、配额、明显的恶意输入 | 精心构造的注入 |
| 模型 | 明显有害的请求 | 对抗性攻击 |
| 网关工具决策 | 未授权的工具、越界的参数 | 授权范围内的滥用 |
| 客户端审批 | 全新形态的攻击 | 无人值守场景;审批疲劳 |
| 沙箱 | 代码执行、宿主机破坏 | 数据外泄 |
| 网关出站 | 敏感信息外泄、非白名单域名 | 隐蔽信道 |
优先级建议(按投入产出比排):
- 网关的工具白名单 + 默认拒绝 —— 成本最低,收益最大,一条配置挡住整类攻击
- 出站域名白名单 —— 直接砍掉 Lethal Trifecta 的第 ③ 要素
- 参数级规则(针对高危工具)—— 成本中等
- 人工审批(针对不可逆操作)—— 成本高,只给最危险的操作
- 流式内容护栏 —— 成本最高,按合规要求决定
第 1 和第 2 条应该是所有生产 Agent 的最低标准。 它们都不需要理解攻击,只需要枚举"允许什么" —— 而这正是第三节那句话的意思:放弃检测攻击,改为检测后果。
六、一个需要明确回答的问题
前面把网关捧得很高。但网关自己被打穿了呢?
这就是 05 篇的内容 —— 2026 年 3 月,LiteLLM 这个装机量最大的 AI 网关,它的 PyPI 包被投毒了。所有把凭证托管给它的用户,凭证一次性全部暴露。
防线越集中,被打穿时的爆炸半径越大。 这是纵深防御无法回避的代价。
下一篇 → 05 - 供应链复盘:一次真实的、完整的攻击,从 CI 里的安全扫描器开始。
← 回到 专题索引 · Agent Infra 板块总览