MCP服务器路径级防护:阻止读取.env文件

目录根限定挡不住沙箱内的.env读取,需在ExtMCP的请求与响应两个钩子上分别拦截。
文件系统MCP服务器的目录根限定只能阻止智能体离开沙箱,却无法约束沙箱内部打开哪个文件,而.env、.ssh/id_rsa、.git/config等凭证文件恰恰就在同一目录里。作者尝试用agentgateway的mcpAuthorization策略按路径拦截,却发现在请求阶段mcp.tool.arguments尚未填充,规则无论路径写什么都得到相同结果。真正的控制点是ExtMCP:一个gRPC服务,在tools/call到达上游前调用CheckRequest检查路径参数,响应返回后调用CheckResponse扫描并脱敏密钥形态内容。两者配合,形成事前阻止与事后兜底两道防线。failClosed则带来真实的可用性代价:策略服务器不可达时,连无害文件读取也会被拒绝。
一个被限定在单一目录下的文件系统MCP服务器,如果智能体明确要求读取.env,它依然会照做。目录根限定(root scoping)只阻止智能体离开沙箱,但它对沙箱内部具体打开哪个文件没有任何约束。而.env、.ssh/id_rsa、.git/config这些文件,恰恰就躺在智能体本应工作的同一个目录里。
r/mcp社区最近的一个帖子描述了这种失败模式:Claude险些通过文件系统MCP读取了生产环境的.env,发帖者最终不得不自己用Rust写了一套防护,因为上游找不到现成的方案。作者为此构建了filesystem-mcp-env-guard项目,试图找到正确的控制点——而第一个尝试的方案,完全行不通。

为什么mcpAuthorization策略挡不住路径
agentgateway内置了MCP授权策略mcpAuthorization,它用CEL规则对mcp.tool.name、mcp.tool.target以及JWT声明(如jwt.sub)进行评估。文档中展示的规则长这样:
mcpAuthorization:
rules:
- allow: |
mcp.tool.name == "echo" ||
mcp.tool.name == "get-sum"
这是一个工具名白名单,它真实有效——作者在本地agentgateway上测试了多种变体,都能正常运行。问题在于,作者需要的是一个能引用read_text_file调用中path参数的规则,从而拒绝包含.env的路径。
但那个字段并不存在。mcp.tool.arguments虽然出现在agentgateway的CEL变量表中,却被标记为post-request:它只在调用执行完毕后为访问日志填充。而mcpAuthorization规则在调用之前求值,此时参数还未填充。
作者没有仅凭文档下结论,而是写了规则,在真实文件系统MCP服务器和agentgateway v1.5.0二进制文件上运行,观察到每一次read_text_file调用的求值结果完全相同,无论路径是什么。工具名规则有效,路径规则无数据可读。
CEL(Common Expression Language) 是 Google 开源的一种非图灵完备的表达式语言,设计用于在安全敏感场景下做快速、可预测的策略求值,常被 Envoy、Kubernetes 等项目用于访问控制。在 agentgateway 的 mcpAuthorization 中,每条 allow 或 deny 规则都是一个 CEL 表达式,求值环境暴露若干变量(如 mcp.tool.name、mcp.tool.target、jwt.sub)。
CEL 的关键限制在于:它只能读取求值时刻实际存在的数据。agentgateway 明确将 mcp.tool.arguments 标注为 post-request,意味着这个变量只有在 tools/call 执行完毕、为访问日志构造记录时才被填充。而授权规则在调用发生之前求值。因此在请求阶段,任何引用 mcp.tool.arguments 的规则都得到空值,无论路径字符串写的是什么,结果都一样。这不是 CEL 表达式的写法问题,而是数据在该阶段根本不存在。理解这一点,才能明白为什么工具名白名单有效、而路径白名单无效。
真正能看到路径的控制点:ExtMCP
参数检查和响应修改都要通过ExtMCP:一个外部gRPC服务,agentgateway在tools/call请求到达上游服务器之前调用它,响应返回后再调用一次。接口定义在ext_mcp.proto中,非常小巧:两个RPC——CheckRequest和CheckResponse,各自返回“放行”“返回修改版本”或“拒绝并返回错误”。
接入方式是一个mcpGuardrails策略块:
config:
adminAddr: "off"
mcp:
port: 13300
policies:
mcpGuardrails:
processors:
- kind: remote
host: "localhost:9001"
failureMode: failClosed
methods:
tools/call: full
targets:
- name: filesystem
stdio:
cmd: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/sandbox"]
methods: tools/call: full意味着两个钩子都会为tools/call运行:CheckRequest在文件系统服务器看到调用之前,CheckResponse在它应答之后。failureMode: failClosed看起来只是一个配置开关,但它比表面上重要得多。
gRPC(gRPC Remote Procedure Call)是一个基于 HTTP/2 的高性能 RPC 框架,使用 Protocol Buffers 定义服务接口,支持双向流、多路复用,并强制要求定义强类型的方法契约。ExtMCP 正是通过一个 gRPC 服务向 agentgateway 暴露两个 RPC:CheckRequest 和 CheckResponse。
gRPC 的强类型契约在这里很关键:ext_mcp.proto 精确定义了请求和响应消息的字段类型,使得网关与外部策略服务器之间的交互不会出现“字段名写错但静默返回空值”这类问题。同时 gRPC 的连接是显式的、可探测的,这也解释了后文 failClosed 行为为何能被察觉——当策略服务器进程被杀掉,网关侧会收到明确的 Connection refused,而不是模糊超时或静默放行。Protocol Buffers 的紧凑编码也让每次 tools/call 多出的两个往返开销保持在很低的量级。
策略服务器如何工作
这个策略服务器大约150行Python。CheckRequest解析JSON-RPC的params,提取name和arguments,对于带路径的工具(read_text_file、read_file、read_media_file、get_file_info,以及参数为paths数组而非单个path的read_multiple_files),将每个路径字符串与拒绝列表比对:.env、.ssh、.git/、credentials、id_rsa、id_ed25519、.aws/、.npmrc、.netrc。
def _check_arguments(tool_name: str, arguments: dict) -> str | None:
fields = PATH_TOOLS.get(tool_name)
if not fields:
return None
for field in fields:
if field not in arguments:
continue
value = arguments[field]
values = value if isinstance(value, list) else [value]
for v in values:
if isinstance(v, str) and (hit := _matches_denied_path(v)):
return hit
return None
匹配成功时,CheckRequest返回一个AuthorizationError,文件系统服务器根本不会打开文件:
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \\
--method tools/call --tool-name read_text_file --tool-arg path=/sandbox/.env
{"error":{"code":"error","message":"path argument matches denied pattern '.env'; filesystem-mcp-env-guard blocks reads of credential-bearing paths"}}
对比一下同一个调用经过没有防护策略的相同网关:
$ npx @modelcontextprotocol/inspector --cli http://localhost:13301/mcp \\
--method tools/call --tool-name read_text_file --tool-arg path=/sandbox/.env
{"content":[{"type":"text","text":"DATABASE_URL=postgres://demo:demopass@localhost:5432/demodb\
API_KEY=sk-demo-FAKE1234567890abcdef\
"}]}
同一服务器,同一沙箱,同一路径。唯一区别是策略服务器是否在链路中。
JSON-RPC 是 MCP(Model Context Protocol)底层使用的消息格式:每次工具调用是一个 JSON 对象,包含 method(如 tools/call)、params(其中 name 是工具名、arguments 是参数字典)以及 id。策略服务器之所以能精确拦截路径,正是因为它解析的是这个 params.arguments 结构,而不是从一段文本里猜。
需要注意 read_text_file 与 read_multiple_files 的参数形态不同:前者接收单个字符串型 path,后者接收字符串数组 paths。这就是 _check_arguments 中先判断 isinstance(value, list) 再逐个遍历的原因。如果只处理单值,数组型调用就会成为绕过拒绝列表的缺口。这种“按工具名映射到其路径字段名”的做法(代码中的 PATH_TOOLS)本质上是一张小型的工具模式表,它必须随上游文件系统服务器新增带路径的工具而更新,否则新工具将默认不受路径检查约束。
兜底机制:文件名安全不代表内容安全
对路径参数的拒绝列表能捕获显而易见的情况,但捕获不了文件名无害、内容却是凭证的文件——比如一个app.log,某行调试日志里贴进了API密钥。这就是CheckResponse的用武之地。
它扫描返回的文本内容,寻找密钥形态的子串(API密钥、password=、Postgres连接字符串、PEM私钥块、AWS访问密钥ID),在响应离开网关前将匹配项脱敏:
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \\
--method tools/call --tool-name read_text_file --tool-arg path=/sandbox/app.log
{"content":[{"type":"text","text":"2026-10-15 boot ok\
2026-10-15 connecting with API_KEY=[REDACTED] to upstream\
2026-10-15 password=[REDACTED] for service account\
"}]}
app.log从未出现在拒绝列表上,也不需要。响应钩子不关心内容来自哪个路径,只关心内容长什么样。两个不同的控制点,两项不同的职责:请求钩子在显而易见的读取发生前阻止它,响应钩子捕获前者漏掉的。
DLP(Data Loss Prevention,数据防泄漏)通常指一类系统性检测并阻止敏感数据外流的机制,涵盖内容指纹、正则规则、机器学习分类、策略编排等。本文的 CheckResponse 只是一个极简的内容扫描器:固定的一组密钥形态正则(API key、password=、Postgres 连接串、PEM 私钥块、AWS 访问密钥 ID),匹配后替换为 [REDACTED]。
它与真正的 DLP 有两点本质差别。其一,模式集是固定的、面向演示凭证形态的,新的密钥格式(如某些云厂商的新版 token)不会自动被覆盖。其二,它只做脱敏而非阻断,匹配到的响应仍会返回给智能体,只是敏感子串被替换。因此它适合作为响应侧的兜底层,而不是唯一的控制点。把它与请求侧的路径拒绝列表组合起来,才形成“事前阻止显而易见 + 事后捕获遗漏”的两道防线,这也正是文中强调两个控制点各司其职的含义。
failClosed的真实代价
failureMode: failClosed意味着策略服务器不可达时,agentgateway会拒绝被防护的调用,而非放行。对于凭证路径控制来说,这是正确的默认值,但它同时也是一个真实的可用性依赖,而不只是一个配置项。
作者在演示中途杀掉策略服务器,重新运行对完全无害的notes.txt的读取:
$ npx @modelcontextprotocol/inspector --cli http://localhost:13300/mcp \\
--method tools/call --tool-name read_text_file --tool-arg path=/sandbox/notes.txt
{"error":{"code":"error","message":"mcpGuardrails checkRequest failed: upstream call failed: Connect: Connection refused (os error 61)"}}
一个不含任何敏感内容的文件被拒绝了,因为网关无法知道策略服务器本会说“可以”。这就是fail-closed的诚实代价:文件系统MCP服务器的可用性,现在被策略服务器的可用性所限制。failOpen移除了这个依赖,却在策略服务器出问题的瞬间重新引入了原始问题。对于携带凭证的路径,fail-closed是唯一不会在高负载下悄悄退回基线的答案。
运行方式与局限
git clone https://github.com/themsquared/filesystem-mcp-env-guard
cd filesystem-mcp-env-guard
AGENTGATEWAY_BIN=/path/to/agentgateway bash scripts/demo.sh
脚本会启动无防护基线,证明.env泄露,拆除后启动防护服务器和防护网关,再运行五次调用(安全读取、.env拒绝、.ssh/id_rsa拒绝、包含一个被拒路径的read_multiple_files调用、响应脱敏兜底),然后杀掉策略服务器展示fail-closed行为。七项检查全部通过。
需要明确的是:拒绝列表是对路径字符串的子串匹配,不是路径策略语言。防护部署前植入的符号链接,或巧妙命名的目录,不是九个字符串检查能捕获的。响应脱敏模式也是针对本演示凭证形态的固定集合,不是通用DLP引擎。两者都是正确形态的控制,范围诚实地限定在:对显而易见情况的第一道防线,位于请求路径中唯一能在读取发生前看到参数的位置。",
相关推荐

OpenAI与Shield合作:Astra智能体如何革新IT故障修复
OpenAI与Shield合作推出Astra智能体,可自动登录电脑和遗留系统,实时修复IT故障,比传统模型快35%到40%,仅在需要人类判断时引入人工。

iPhone Duo续航实测:一天一充跑不掉,大屏是耗电元凶
iPhone Duo续航实测:重度使用一天需至少充电一次,6.5小时亮屏约剩30%电量。大屏展开频率是耗电关键,MagSafe电池背夹可救场,长期衰减同样值得关注。

Kimi K3.1 Pro开源发布:2.8万亿参数逼近Opus
Moonshot发布开源MoE模型Kimi K3.1 Pro,2.8万亿总参数、1040亿激活参数,Terminal Bench 4.0仅落后Opus 5.5不到1分,单任务成本约三分之一,API价格减半,开源权重11月4日开放。