Skip to main content

04 - 防线在哪一层:网关、客户端,还是模型

前置01 - 威胁模型03 - 工具投毒与 Rug Pull。 读过 Agent 网关 · 05 - MCP 网关 会更顺,本篇会大量回引。

本篇回答:知道了会被怎么打,那该在哪儿设防?每道防线各能拦住什么、拦不住什么?

一、一次工具调用经过的六道关卡

先把链路铺开,防线的位置就清楚了:

一次工具调用能设防的六个位置 —— 按请求经过的先后排列用户请求① 网关 · 入站身份 · 配额输入过滤拦得住 · 越权调用与超配额的请求拦不住 · 藏在正常文本里的提示注�入② 模型自身的拒绝能力拦得住 · 直白的有害请求拦不住 · 换个说法就绕开,不算边界③ 网关 · 工具决策工具白名单参数校验拦得住 · 不在白名单的工具与越界参数拦不住 · 白名单内的工具被合理地滥用④ 客户端 · 审批危险操作弹窗给人确认拦得住 · 完全没见过的新型攻击拦不住 · 无人值守的后台任务、审批疲劳⑤ 执行环境沙箱最小权限拦得住 · Agent 对宿主机的破坏拦不住 · 数据外泄,能读能上网就够了⑥ 网关 · 出站返回值过滤出站域名白名单拦得住 · 往陌生域名回传数据拦不住 · 藏在允许域名里带走的数据网关执行(① ③ ⑥)—— 唯一同时看得见「谁在调」和「调了什么工具」的位置客户端执行(④)执行环境(⑤)模型自身(②)—— 不是边界
三道关卡落在网关上不是巧合 —— 只有网关同时知道请求来自谁、以及模型打算调用哪个工具带什么参数。② 用灰色是因为它不构成边界,下一节解释原因。

六个位置,每一个都能设防,但能力和代价完全不同。图里这几个词先解释一下:

意思
提示注入攻击者把指令伪装成普通数据喂给模型。对模型来说系统提示词和一封邮件的正文处在同一个上下文里,地位相同,所以它分不清哪句是命令,详见 01 篇
工具白名单事先规定这次会话只允许调用哪几个工具。模型即使被诱导去调别的,网关这一层直接拒绝
参数校验比白名单更细一层:不只看调了哪个工具,还看参数长什么样。比如允许调 send_email 但收件人域名必须是公司内部
沙箱一个受限的执行环境,Agent 在里面跑代码伤不到宿主机。注意它限制的是破坏能力,不是信息搬运能力
Lethal Trifecta「致命三要素」:能接触私有数据 + 摄入不可信内容 + 有对外通道。三者同时具备时数据外泄就无法从原理上避免,详见 01 篇

二、逐道防线的能力边界

② 模型自身:必要,但不能当防线

模型经过对齐训练,会拒绝明显有害的请求。这有用,但不能作为安全边界,原因在 01 篇说过:指令和数据在同一个上下文里,攻击者总能找到新的表达方式。

判断标准很简单:一个防线如果无法给出确定性的保证,它就是缓解措施,不是边界。 模型的拒绝能力属于前者。

④ 客户端人工审批:最强,但最贵

Claude Code 那类工具用的是这条路 —— 危险操作弹窗给人确认。

这是唯一能挡住"完全没见过的新型攻击"的防线,因为判断的是人。

它的三个代价也很实在:

  1. 审批疲劳。弹得太频繁,人就开始无脑点"同意" —— 这正是 01 篇里 ASI09(利用人对 Agent 的信任)的核心。
  2. 只能保护交互式场景。跑在后台的批处理 Agent 没人可问。
  3. 人必须看得懂。弹窗里如果是一串 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 而不是 searchsearch 只要字符串里有一段匹配就算过 —— /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 的延迟有窗口期
全部攒完再检查退化成非流式完整

没有免费的选项。 这也是为什么很多网关的流式护栏默认是关的。

五、纵深防御:每层拦什么

单独任何一层都不够。合理的分工:

拦什么拦不住什么
网关入站身份、配额、明显的恶意输入精心构造的注入
模型明显有害的请求对抗性攻击
网关工具决策未授权的工具、越界的参数授权范围内的滥用
客户端审批全新形态的攻击无人值守场景;审批疲劳
沙箱代码执行、宿主机破坏数据外泄
网关出站敏感信息外泄、非白名单域名隐蔽信道

优先级建议(按投入产出比排):

  1. 网关的工具白名单 + 默认拒绝 —— 成本最低,收益最大,一条配置挡住整类攻击
  2. 出站域名白名单 —— 直接砍掉 Lethal Trifecta 的第 ③ 要素
  3. 参数级规则(针对高危工具)—— 成本中等
  4. 人工审批(针对不可逆操作)—— 成本高,只给最危险的操作
  5. 流式内容护栏 —— 成本最高,按合规要求决定

第 1 和第 2 条应该是所有生产 Agent 的最低标准。 它们都不需要理解攻击,只需要枚举"允许什么" —— 而这正是第三节那句话的意思:放弃检测攻击,改为检测后果。

六、一个需要明确回答的问题

前面把网关捧得很高。但网关自己被打穿了呢?

这就是 05 篇的内容 —— 2026 年 3 月,LiteLLM 这个装机量最大的 AI 网关,它的 PyPI 包被投毒了。所有把凭证托管给它的用户,凭证一次性全部暴露。

防线越集中,被打穿时的爆炸半径越大。 这是纵深防御无法回避的代价。

下一篇05 - 供应链复盘:一次真实的、完整的攻击,从 CI 里的安全扫描器开始。

← 回到 专题索引  ·  Agent Infra 板块总览