Agent 基建系列(二):基于 Proxy 的凭证不入沙箱和网络管控方案

我们在做开源的云端托管 Agent github.com/superduck-a... ,要访问需要 Token 的 MCP Server 和外部 API。常见做法是把 Token 写进环境变量或配置文件。

Sandbox 里会跑模型生成的命令、仓库脚本、第三方依赖,它们都能读这些配置。Token 一旦放进去,后面谁读了、发到哪、还能不能收回,平台基本管不住。

因此我们做了一套基于 Proxy 的方案:凭证留在控制面,在受控出口完成注入,并对出站请求做安全管控。

问题:给凭证,还是给调用能力

Agent 需要能调外部服务,但不该直接持有长期 Token。平台要交付的是调用能力:这次 Session 可以访问哪些目标,每次请求是否放行,都由出口决定。上游凭证只在 Gateway 转发前写入,不进 Sandbox。

要守住这条边界,先得分清两类身份。

平台 Session 身份可以进 Sandbox,只用来向平台证明这是哪一次运行。真实上游不认它。

上游长期凭证只留在控制面,例如模型 API Key、访问 MCP 或私有仓库用的 Token。Gateway 转发时才写入。

Gateway 在转发请求时写入这些凭证。它们不会进入 Sandbox 的环境变量、配置文件或磁盘。接下来介绍的方案,都是为了守住这条边界。

有三种情况需要处理:

  • 模型调用 模型请求的上游和用途固定,因此 SDK 直接调用服务后端提供的 /v1/messages。LLM Gateway 会移除客户端带来的认证信息,换成服务端配置的模型访问凭证。

  • MCP MCP Client 使用服务端生成的代理地址。服务端从中还原真实的 MCP 地址,确认 Session 后再写入 Bearer Token。

  • 通用 HTTPS curl、Git、各类 SDK 走 HTTPS_PROXY 进受控出口。Gateway 解密请求后核对 Session 和网络规则,再按目标 URL 匹配并注入凭证。

接下来先来看已经完成凭证替换的 LLM Gateway 和 MCP Gateway 这两条专用链路。它们的目标地址和请求用途都很明确。

LLM Gateway 怎样替换模型访问凭证

模型请求走专用入口。Agent 把 Base URL 指到平台 /v1/messages,用 Runtime 下发的 Session Token 访问。

看一次实际调用更清楚。Sandbox 启动时,平台下发的配置大致是:Base URL 指向平台,短期 Token 走 auth,不写进环境变量,也不出现真实的 ANTHROPIC_API_KEY:

json 复制代码
{
  "startup_context": {
    "api_base_url": "http://agent-control.example.com:38080"
  },
  "auth": [
    { "type": "session_ingress", "token": "sk-..." },
    { "type": "anthropic_oauth", "token": "sk-..." }
  ]
}

于是请求先打到平台,而不是模型厂商:

http 复制代码
POST /v1/messages HTTP/1.1
Host: agent-control.example.com:38080
X-Api-Key: sk-...
Content-Type: application/json

这个 Token 通过鉴权后,平台向真正的模型厂商发起调用:

http 复制代码
POST /anthropic/v1/messages HTTP/1.1
Host: api.deepseek.com
X-Api-Key: <服务端配置的真实上游 key>

沙箱从头到尾只握着 sk-... 这个临时身份。

MCP HTTP Gateway 怎样注入 Token

MCP 这条专用入口,做法和模型链路同一思路:Sandbox 只拿平台身份,真实上游凭证留在控制面。不同的是,模型上游固定走 /v1/messages,而 HTTP MCP 的目标地址各不相同,控制面要根据 MCP URL 注入不同的 Token。

比如,我们在本地开发 mcp 配置是这样写,由 agent 直接访问 notion 服务器:

json 复制代码
{
  "mcpServers": {
    "notion": {
      "type": "http",
      "url": "https://mcp.notion.com/mcp"
    }
  }
}

云端跑的 Agent MCP 地址虽然也是由用户自己配置的,但发给 Agent 的配置是由服务端控制的。配置从服务端发出时,就可以把真实 URL 换成与当前 Session 绑定的代理入口------真实地址只留在查询参数里,上游 Token 也不会写进配置:

json 复制代码
{
  "mcpServers": {
    "notion": {
      "type": "http",
      "url": "https://agent-control.example.com/<session-mcp-route>?mcp_url=https%3A%2F%2Fmcp.notion.com%2Fmcp"
    }
  }
}

MCP Client 访问这个入口时,带的是平台 Session 身份,不是上游 Token:

http 复制代码
POST /<session-mcp-route>?mcp_url=https%3A%2F%2Fmcp.notion.com%2Fmcp HTTP/1.1
Host: agent-control.example.com
Authorization: Bearer <session-ingress-jwt>
Content-Type: application/json

Gateway 先校验 Session JWT,确认路由与身份属于同一次 Session,再按网络策略检查 mcp_url 指向的真实地址。通过后,它删掉客户端带来的认证 Header,用真实 mcp_url 匹配 Static Bearer,在控制面写入 Authorization: Bearer <resolved-token>,再转发到真实 MCP Server。

模型和 MCP 都能走专用入口,因为目标事先已知。curl、Git、各类 SDK 则不然------请求可能发往任意 HTTPS 地址。下一节先看怎样把这类流量接到控制面。

通用路径:怎样接到控制面

如果平台能够控制宿主机网络,可以用 Service Mesh 接管出站流量。云厂商提供的 Sandbox 往往不允许平台添加 Sidecar 或修改底层网络,因此这里从应用进程能够控制的入口入手。

这条路径先解决三个问题:接住客户端流量,确认请求属于哪个 Session,再判断目标地址能否访问。

HTTPS_PROXY 是什么

HTTPS_PROXY 是 HTTP 客户端普遍遵循的代理约定,不是内核提供的透明代理。客户端访问 HTTPS 地址时,会先连接变量中指定的 Proxy。

enviroment-runner 会在 Agent 启动前设置一次环境变量:

bash 复制代码
HTTPS_PROXY=http://127.0.0.1:38373

环境变量会由父进程传给子进程。Agent 启动的 Bash、curl、Git、SDK 和包管理器只要遵循这套代理约定,就可以通过同一个出口发起 HTTPS 请求。

以 curl 访问 https://api.example.com 为例,它先连接 Sandbox 内的 Local relay,再请求建立到目标服务的隧道:

http 复制代码
CONNECT api.example.com:443 HTTP/1.1
Host: api.example.com:443

Local relay 通过一条带 Session 身份的通道,把 CONNECT 送到 Egress Gateway。Gateway 校验身份和网络规则后返回 200 Connection Established,Local relay 再把响应转回 curl。curl 随后才在这条隧道中开始 TLS 握手。

HTTPS_PROXY 只改变连接的第一站,不会取消 HTTPS。Local relay 也不解密请求,只在本地连接与服务后端之间搬运字节。

Gateway 根据 Session 加载对应的网络规则。规则允许访问目标以后,Gateway 才解析域名,为下一步连接真实服务做准备。

Local relay 如何把 CONNECT 送到服务后端

每条 CONNECT 对应一条从 Local relay 到控制面的 WebSocket。服务端在 WebSocket 升级前验证 Session JWT。

WebSocket 建立后,第一个 frame 带上原始 CONNECT,以及用于绑定同一 Session 的 Proxy-Authorization。后续 frame 承载双向字节流。WebSocket 在这里仅负责把本地连接送到控制面。

下面的 Wireshark 抓包展示了用 agent 调用 curl 访问 https://www.baidu.com/robots.txt 的流量,可以看到其中的 WebSocket 升级、首个 CONNECT frame 和服务端返回的 200 Connection Established:

CONNECT 只告诉 Proxy 要连到哪里

隧道建立以后,curl 传输的是 TLS 数据。请求访问 /mcp/tools/call 还是 /mcp-admin、是否带有 Authorization、上游返回了什么 Redirect,此时都藏在加密字节中。

至此,通用路径已经完成前半段:谁在访问、能不能连这个 host,控制面都知道了。但和前面的 MCP 注入不同,这里还看不到 path 和 Header。没有它们,就无法按完整 URL 选凭证,也无法做更细的安全管控------例如按域名或完整 URL 拦截和审计请求。下一节用 MITM 补上这一环。

MITM:让 Gateway 读到 HTTP 请求

做法是把一条端到端 TLS,拆成彼此独立的两段。面对 Sandbox,Gateway 充当 TLS Server,用动态签发的目标域名证书完成握手,从而解密并读出 HTTP 请求;面对真实上游,它再作为 TLS Client 另建一条连接,并按正常规则校验对方证书。Sandbox 与上游之间并不共享同一条 TLS 会话------这就是这里的 MITM。

Gateway 如何生成站点证书

Sandbox 一侧的握手对象变成了 Gateway,但 curl 仍会校验证书域名是不是它要访问的 api.example.com。因此 Gateway 不能出示一张随便的证书,必须按目标域名动态签发站点证书。

具体分两层。控制面持有一把长期的 CA private key;服务启动时用它生成自签根证书,作为后续签发者。某个域名第一次经过 Gateway 时,再按需签发一张短期 leaf:由内部根 CA 签发,SAN 只含当前域名或 IP,有效期最长 24 小时,并缓存在内存里,避免重复签发。

Sandbox 只拿到公开的根证书,用来验证 Gateway 出示的 leaf。CA private key 和 leaf private key 始终留在控制面。

合并,而不是替换系统 CA

Sandbox 镜像自带的 CA bundle 只认识公共 CA,并不认识 Gateway 的内部根证书。curl 收到 Gateway 动态签发的站点证书时,会因为找不到可信的签发者而拒绝连接。

直接换掉系统 CA bundle 也不可行。这样虽然能访问 Gateway,却会让 GitHub、软件包仓库和其他公网服务失去信任。Runtime 的做法是为当前 Session 新建一份合并后的 CA bundle:完整复制系统 CA,再追加 Gateway 根证书。客户端改用这份文件后,两类证书都能正常验证。

Runtime 读取 /etc/ssl/certs/ca-certificates.crt,从 GET /v1/code/upstreamproxy/ca-cert 下载 Gateway 临时根证书,然后把两者写入当前 Session 使用的合并后的 CA bundle。

一次真实的 curl 握手

Agent 访问 https://www.baidu.com/robots.txt 时,Gateway 会为 www.baidu.com 动态签发证书。下面的 curl -v 输出:

console 复制代码
* Connected to 127.0.0.1 (127.0.0.1) port 41813
* Establish HTTP proxy tunnel to www.baidu.com:443
> CONNECT www.baidu.com:443 HTTP/1.1
> Host: www.baidu.com:443
>
< HTTP/1.1 200 Connection Established
<
* CONNECT tunnel established, response 200
* TLSv1.3 (OUT), TLS handshake, Client hello (1)
* CAfile: /root/.agent-runtime/ca-bundle.crt
* TLSv1.3 (IN), TLS handshake, Server hello (2)
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256
* Server certificate:
*  subject: CN=www.baidu.com
*  subjectAltName: host "www.baidu.com" matched cert's "www.baidu.com"
*  issuer: O=My Agent Egress Gateway; CN=Agent Egress Gateway MITM CA
*  SSL certificate verify ok.
> GET /robots.txt HTTP/1.1
> Host: www.baidu.com
> User-Agent: curl/8.5.0
> Accept: */*

200 Connection Established 表示 CONNECT 隧道已经建立。随后发生的 TLS 握手属于 Sandbox 与 Gateway 之间的连接。证书中的域名仍是 www.baidu.com,签发者则是内部根 CA。SSL certificate verify ok 说明客户端已经通过合并后的 CA bundle 建立信任。

到这一步,通用 MITM 需要的几块都齐了:Sandbox 里有合并后的 CA bundle,Gateway 能按域名签发站点证书,两侧 TLS 也能分别建起来。请求进来时,Gateway 还会核对 CONNECT、SNI 和 HTTP Host 是否一致,并去掉只给 Proxy 用的 Header。

控制面读到 HTTP 后,会按目标 URL 从当前 Session 可用的 Vault 中匹配凭证,覆盖请求中的认证 Header,再转发给真实上游。通用 HTTPS 的凭证注入在这里完成闭环。

这些机制要真正生效,客户端还必须正确设置 Proxy,并信任 Gateway 根证书。不同运行时的配置入口和默认行为并不相同。

不同的工具和编程语言怎样使用 Proxy 和 CA

HTTPS_PROXY 是客户端约定,不是所有语言都必须遵守的规范。一个程序是否经过 Proxy,取决于它使用的网络库、运行时版本和启动参数。CA 配置也有同样的问题:不同运行时从不同入口读取信任材料,而且不少运行时只在进程启动时加载一次。

curl、Python Requests 和依赖 OpenSSL 的通用客户端可以使用合并后的 CA bundle:

bash 复制代码
SSL_CERT_FILE=/root/.agent-runtime/ca-bundle.crt
REQUESTS_CA_BUNDLE=/root/.agent-runtime/ca-bundle.crt
CURL_CA_BUNDLE=/root/.agent-runtime/ca-bundle.crt

Node.js 可以通过 NODE_EXTRA_CA_CERTS 在默认公共 CA 之外追加内部根证书:

bash 复制代码
NODE_EXTRA_CA_CERTS=/root/.agent-runtime/gateway-root-ca.crt

Node.js 22.21 及以上版本为 fetch()、http.request() 和 https.request() 提供内置环境代理支持,但需要显式设置 NODE_USE_ENV_PROXY=1 或使用 --use-env-proxy。老版本和自定义 Agent 仍可能绕过环境代理。具体行为可查阅 Node.js 内置 Proxy 文档。

Java 不读取 HTTPS_PROXY 这套环境变量。JDK 的 HttpClient 和 URLConnection 使用 JVM system properties 或代码中的 ProxySelector。Java 还需要自己的 truststore。当前 Runtime 通过 JAVA_TOOL_OPTIONS 同时传入 Proxy、绕过列表和 PKCS12 truststore:

bash 复制代码
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/root/.agent-runtime/java-truststore.p12 \
  -Djavax.net.ssl.trustStorePassword=changeit \
  -Djavax.net.ssl.trustStoreType=PKCS12 \
  -Dhttps.proxyHost=127.0.0.1 \
  -Dhttps.proxyPort=38373 \
  -Dhttp.nonProxyHosts=localhost|127.*|0.*|::1|169.254.*|agent-control.example.com|*.agent-control.example.com|*.svc.cluster.local \
  -Djdk.http.auth.tunneling.disabledSchemes= \
  -Djdk.http.auth.proxying.disabledSchemes="

C 和 C++ 中的 curl 和 libcurl 支持这套环境变量,其他网络库可能需要显式配置 Proxy 和 CA。

另一些客户端默认忽略环境代理:

  • aiohttp 需要在 ClientSession 中设置 trust_env=True;
  • 自己创建 Go http.Transport 时,需要保留 Proxy: http.ProxyFromEnvironment;
  • 完全绕过 net/http、直接建立网络连接的 Go 客户端必须自行处理代理协议;

Runtime 会把这些兼容提示放进系统提示词,让 Agent 在运行具体工具前补上对应参数。这可以覆盖大多数常见场景。

如果工具既不读取环境变量,也不提供自己的 Proxy 选项,Agent 会报告不兼容并停止调用,不会通过临时开放直连来绕过控制面的安全管控。

Git 的鉴权信息如何处理

私有仓库同样需要凭证。常见做法是把 Personal Access Token 写进 HTTPS URL,或者把 SSH private key 放进 Sandbox:

text 复制代码
https://user:token@git.example.com/team/private-repo.git
git@git.example.com:team/private-repo.git

两种做法都会把敏感的 Vault 信息交给 Sandbox。

我们的处理分两步:先把 Git 协议统一成 HTTPS,再走前面的受控出口。

Runtime 启动时设置一组环境变量,不必改 Agent 生成的每条 git 命令。后续 git clone、git fetch 都会继承:

bash 复制代码
GIT_CONFIG_COUNT=3
GIT_CONFIG_KEY_0=credential.interactive
GIT_CONFIG_VALUE_0=false
GIT_CONFIG_KEY_1=url.https://github.com/.insteadOf
GIT_CONFIG_VALUE_1=git@github.com:
GIT_CONFIG_KEY_2=url.https://github.com/.insteadOf
GIT_CONFIG_VALUE_2=ssh://git@github.com/
GIT_EDITOR=true
GIT_SSL_CAINFO=/root/.agent-runtime/ca-bundle.crt
GIT_TERMINAL_PROMPT=0

上面几项大致等于:

bash 复制代码
git \
  -c credential.interactive=false \
  -c url.https://github.com/.insteadOf=git@github.com: \
  -c url.https://github.com/.insteadOf=ssh://git@github.com/ \
  clone ...

其中两条 insteadOf 会在建连前把 SSH remote 改成 HTTPS:

text 复制代码
git@github.com:team/private-repo.git
ssh://git@github.com/team/private-repo.git

→ https://github.com/team/private-repo.git

这样 Sandbox 不会去连 22 端口,也不用找 SSH private key。改写后的 HTTPS 流量继续读 HTTPS_PROXY,进入受控出口。

其余几项是为了非交互运行:credential.interactive=false 和 GIT_TERMINAL_PROMPT=0 避免认证失败后卡住等人输入;GIT_EDITOR=true 避免弹出编辑器;GIT_SSL_CAINFO 指向前文的合并 CA bundle,用来验证 Gateway 动态签发的证书。

这些环境变量只约束连接方式,本身不含上游凭证。真正的仓库认证仍在服务端完成。

后面的路径和通用 HTTPS 一样:先到本机 Local relay,再经带 Session 身份的通道进服务端。服务端核对本 Session 是否绑定了目标仓库,取出可用凭证,再向真正的 Git 服务发 HTTPS 请求。

这条链路不能转发什么

当前 Proxy 不是任意协议转发器。它的能力边界,可以直接从前面几节的设计前提推出来。

入口只接 CONNECT host:443。 客户端先走 HTTPS 代理约定,再由 Local relay 把 CONNECT 送到控制面。因此非 443 端口上的 HTTPS,以及 PostgreSQL、MySQL、Redis 这类原始 TCP 协议,一开始就不会进入这条链路。

MITM 终止后,只按 HTTP/1.1 读请求。 Gateway 解密的目的,是看清 path、Header,再决定是否注入凭证。当前实现只协商并解析 HTTP/1.1,所以 gRPC、只支持 HTTP/2 的 API,以及应用层的上游 WebSocket Upgrade,都不在覆盖范围。

Sandbox 必须信任 Gateway 动态签发的站点证书。 客户端会用合并后的 CA bundle 验证 Gateway 出示的证书。启用证书固定(certificate pinning)的客户端会拒绝内部签发的证书,例如部分 Snowflake 和 ngrok 场景。

要求客户端证书的 mTLS 也不兼容。Sandbox 实际上在与 Gateway 握手,客户端证书无法按原来的方式直接交给真实服务。

这些限制来自协议和信任模型,不是配置开关没打开。关闭 TLS 校验、改写端口、把上游凭证放回 Sandbox,或临时放开直连,都不能当作替代方案。Agent 遇到不支持的目标时,应报告当前限制并停止调用。

小结

好的安全边界并不要求 Agent 什么都不能做,而是让它只能通过可观察、可撤销、受约束的方式行动。

本文是 Agent 运行环境基建系列的第 2 篇。接下来我们计划通过这个系列逐步构建完整的 Agent 运行环境基建,包括运行时、文件系统、Session 管理、监控告警、安全管控等,欢迎关注后续的实战总结。

相关推荐
挖坑的张师傅17 分钟前
用 FUSE 为云端 Agent 构建文件系统
后端·agent
源于花海31 分钟前
LangChain学习:理解构建模块与核心组件
langchain·prompt·api·agent·模块与核心组件
隐向量38 分钟前
给Agent装上连接外部世界的双手 - 工具调用
agent
qinglongcn40 分钟前
“完成”是什么意思——Plan 步骤状态机的七个 Bug
agent·ai编程
米小虾42 分钟前
给正在干活的 Agent 插一句话:steering 的原理与实现
人工智能·agent
张彦峰ZYF1 小时前
放行、排队与拒绝:限流设计的算法选型、数据结构与并发代价
后端·架构·滑动窗口·漏桶·令牌桶·高并发+限流
jerrywus1 小时前
让 AI 帮你部署 Vercel,报错了也不用手动搬日志
前端·agent·mcp
Trae_陈冀1 小时前
写详细设计总堆代码没逻辑?我做了个自动生成工具,3套模板+规范内置,输出直接过评审
架构·agent
Tokenfree1 小时前
用户一句「忽略以上指令」,客服机器人就开始骂人:提示词注入的四类入口和四层防线
后端