Gemini CLI 沙箱网络出口隔离:GEMINI_SANDBOX_PROXY_COMMAND 代理脚本原理与完整示例
【免费下载链接】gemini-cliAn open-source AI agent that brings the power of Gemini directly into your terminal.项目地址: https://gitcode.com/GitHub_Trending/gemi/gemini-cli
本文以仓库中的示例代理脚本 docs/examples/proxy-script.md 为主体,讲透 Gemini CLI 沙箱的"代理化网络出口"机制:如何用GEMINI_SANDBOX_PROXY_COMMAND指定一个只放行特定域名 HTTPS 流量的自研代理,以及该代理在 macOS Seatbelt 与 Docker/Podman 两条沙箱路径下是如何被自动拉起、注入环境变量、与沙箱生命周期联动的。读完后你可以复制并改造示例脚本,为 CLI 的沙箱命令实现域名级的出站网络白名单。
1. 为什么需要代理脚本:沙箱的出口网络控制
Gemini CLI 的沙箱(sandboxing)用于把 shell 命令、文件写入等高风险操作与宿主系统隔离开,完整的沙箱方案(Seatbelt、Docker/Podman、gVisor、LXC)见 docs/cli/sandbox.md。其中有一类 profile 是专门的"代理化"变体,通过SEATBELT_PROFILE环境变量选择:
permissive-proxied:写操作受限,网络流量经由代理restrictive-proxied:严格限制,网络流量经由代理strict-proxied:读写都受限,网络流量经由代理
从 Seatbelt 策略文件源码可以看到,*-proxied变体之所以"经由代理",是因为策略里只放开了对localhost:8877的出站 TCP 连接,其余网络流量一律默认拒绝。以 sandbox-macos-strict-proxied.sb 为例:
;; allow outbound network traffic through proxy on localhost:8877 ;; set `GEMINI_SANDBOX_PROXY_COMMAND=<command>` to run proxy alongside sandbox ;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) (allow network-outbound (remote tcp "localhost:8877"))这就引出了示例代理脚本存在的意义:GEMINI_SANDBOX_PROXY_COMMAND环境变量的值是一个命令,CLI 会在启动沙箱的同时用它拉起一个监听:::8877的代理服务器(:::表示所有网络接口,含 IPv4 映射地址)。沙箱内所有出站流量被迫先打到这个代理,而代理内部再做域名/端口级的二次过滤——这是仓库文档 CONTRIBUTING.md 中"Proxied networking"一节描述的能力,且所有沙箱方法(包括 Seatbelt 的*-proxiedprofile)都支持。
2. 示例代理脚本完整解析
下面完整继承 docs/examples/proxy-script.md 给出的示例脚本。该脚本用 Node.js 实现一个 CONNECT 隧道代理:只允许HTTPS连接到example.com:443(以及googleapis.com),拒绝一切其他请求。
#!/usr/bin/env node /** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ // Example proxy server that listens on :::8877 and only allows HTTPS connections to example.com. // Set `GEMINI_SANDBOX_PROXY_COMMAND=scripts/example-proxy.js` to run proxy alongside sandbox // Test via `curl https://example.com` inside sandbox (in shell mode or via shell tool) import http from 'node:http'; import net from 'node:net'; import { URL } from 'node:url'; import console from 'node:console'; const PROXY_PORT = 8877; const ALLOWED_DOMAINS = ['example.com', 'googleapis.com']; const ALLOWED_PORT = '443'; const server = http.createServer((req, res) => { // Deny all requests other than CONNECT for HTTPS console.log( `[PROXY] Denying non-CONNECT request for: ${req.method} ${req.url}`, ); res.writeHead(405, { 'Content-Type': 'text/plain' }); res.end('Method Not Allowed'); }); server.on('connect', (req, clientSocket, head) => { // req.url will be in the format "hostname:port" for a CONNECT request. const { port, hostname } = new URL(`http://${req.url}`); console.log(`[PROXY] Intercepted CONNECT request for: ${hostname}:${port}`); if ( ALLOWED_DOMAINS.some( (domain) => hostname == domain || hostname.endsWith(`.${domain}`), ) && port === ALLOWED_PORT ) { console.log(`[PROXY] Allowing connection to ${hostname}:${port}`); // Establish a TCP connection to the original destination. const serverSocket = net.connect(port, hostname, () => { clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); // Create a tunnel by piping data between the client and the destination server. serverSocket.write(head); serverSocket.pipe(clientSocket); clientSocket.pipe(serverSocket); }); serverSocket.on('error', (err) => { console.error(`[PROXY] Error connecting to destination: ${err.message}`); clientSocket.end(`HTTP/1.1 502 Bad Gateway\r\n\r\n`); }); } else { console.log(`[PROXY] Denying connection to ${hostname}:${port}`); clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); } clientSocket.on('error', (err) => { // This can happen if the client hangs up. console.error(`[PROXY] Client socket error: ${err.message}`); }); }); server.listen(PROXY_PORT, () => { const address = server.address(); console.log(`[PROXY] Proxy listening on ${address.address}:${address.port}`); console.log( `[PROXY] Allowing HTTPS connections to domains: ${ALLOWED_DOMAINS.join(', ')}`, ); });2.1 关键设计点逐段说明
白名单常量(第 25–27 行)
const PROXY_PORT = 8877; const ALLOWED_DOMAINS = ['example.com', 'googleapis.com']; const ALLOWED_PORT = '443';PROXY_PORT = 8877不是随意选的:沙箱策略只放行localhost:8877,CLI 内部默认的代理地址也是http://localhost:8877(见 sandbox.ts 的默认值),端口必须与这两处对齐。ALLOWED_DOMAINS是出站白名单,注意第 46 行的匹配逻辑hostname == domain || hostname.endsWith('.${domain}')——它同时允许example.com本体与其任意子域(如api.example.com),但不会误放notexample.com(因为endsWith('.example.com')带点号前缀)。ALLOWED_PORT = '443'与端口做字符串严格比较,只放行 TLS 默认端口。
普通 HTTP 请求一律 405(第 29–36 行)
const server = http.createServer((req, res) => { // Deny all requests other than CONNECT for HTTPS res.writeHead(405, { 'Content-Type': 'text/plain' }); res.end('Method Not Allowed'); });这个处理器捕获所有非 CONNECT的 HTTP 请求(例如沙箱内curl http://...的明文访问),统一以405 Method Not Allowed拒绝。它同时还有一个"副产品"作用:CLI 用它做就绪探测——CLI 启动后会反复向http://localhost:8877发 curl 探测,只要端口能应答(哪怕返回 405)就认为代理已就绪(见 sandbox.ts 的等待循环)。
CONNECT 隧道放行逻辑(第 38–74 行)
HTTPS 客户端访问代理时会发送CONNECT host:443请求,server.on('connect', ...)回调中的req.url即"hostname:port"形式。脚本的处理链路是:
- 用
new URL('http://${req.url}')安全地拆出hostname与port; - 域名命中白名单且端口为 443 → 调用
net.connect(port, hostname, ...)向真实目标建立 TCP 连接,成功后向客户端写HTTP/1.1 200 Connection Established并pipe双向转发,完成隧道。注意serverSocket.write(head):head是客户端已随 CONNECT 一起发出的首段数据,不能丢; - 上游连接失败 → 回
502 Bad Gateway;域名/端口不匹配 → 直接clientSocket.end('HTTP/1.1 403 Forbidden')掐断; - 客户端主动挂断等 socket 错误只打日志、不做处理,避免异常打断其他连接。
由于代理只做域名匹配后盲目建 TCP 隧道,它属于"连接建立前的准入控制":一旦隧道建立,代理看不到 TLS 加密后的具体内容。这是设计权衡——保持实现极简,同时把出口收敛到白名单域名的 443 端口。
监听方式(第 76 行)
server.listen(PROXY_PORT)未指定 host,Node.js 会监听所有接口(等价于:::8877),正好满足 CONTRIBUTING.md 对代理命令"必须监听:::8877"的要求——因为在容器路径下,代理容器与沙箱容器通过独立的 Docker 网络互通,代理必须对容器网络可达,而不是只对宿主回环可达。
3. CLI 如何驱动代理:两条沙箱路径的源码证据
GEMINI_SANDBOX_PROXY_COMMAND的完整生命周期都在 packages/cli/src/utils/sandbox.ts 中实现。从源码结构看,macOS Seatbelt 路径和容器路径的行为差异很大,值得分别说明。
3.1 macOS Seatbelt 路径:代理作为宿主进程运行
Seatbelt 只限制子进程(沙箱内的 Gemini CLI 进程),代理本身运行在宿主侧。源码 sandbox.ts#L229-L286 的流程:
- 读取命令:
const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; - 注入代理环境变量:沙箱子进程的环境会被写入
HTTPS_PROXY/https_proxy/HTTP_PROXY/http_proxy四个大小写变体(小写变体是curl等工具要求的),取值优先级为已有的HTTPS_PROXY→https_proxy→HTTP_PROXY→http_proxy,都没有时默认http://localhost:8877;若宿主设置了NO_PROXY/no_proxy也会一并透传; - 拉起代理:
spawn(proxyCommand, { shell: true, detached: true }),detached: true使代理处于独立进程组,退出时对整个进程组发SIGTERM干净地停掉; - 等待就绪:
until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done,每 0.25 秒探测一次端口; - 故障联动:代理进程意外退出时,CLI 会向沙箱进程发
SIGTERM并抛出FatalSandboxError(Proxy command ... exited with code ...),即代理挂掉则沙箱整体终止,避免沙箱在无出口约束下继续运行; - 日志:代理的 stderr 以
[PROXY STDERR]前缀写入 debugLogger;stdout 的转发被有意注释掉,源码注释说明原因是"干扰 ink 的渲染"。
3.2 容器路径(Docker/Podman):代理跑在独立容器里
容器场景下沙箱容器处于一个--internal的 Docker 网络中(无宿主机出口),代理则运行在自己的容器里,同时连接两个网络:一个可访问宿主的外部网络,和一个--internal的沙箱网络。源码链路:
- 网络常量定义在 sandboxUtils.ts#L14-L15:
export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox'; export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy'; - 环境变量重写(sandbox.ts#L520-L544):把代理地址中的
localhost替换为SANDBOX_PROXY_NAME(即gemini-cli-sandbox-proxy),再以--env HTTPS_PROXY=...等形式注入沙箱容器——因为从沙箱容器视角看,代理的 DNS 名字就是那个容器名; - 双网络编排(sandbox.ts#L546-L565):只要设置了
GEMINI_SANDBOX_PROXY_COMMAND,沙箱网络就以--internal创建,同时额外创建gemini-cli-sandbox-proxy网络供代理容器使用。源码注释说明这样做的动机:让代理在 macOS 上 rootless podman(host ↔ VM ↔ container 的隔离拓扑)下也能工作; - 代理容器启动(sandbox.ts#L792-L864):
- 以
--name gemini-cli-sandbox-proxy、-p 8877:8877、挂载当前工作目录运行,复用沙箱同一镜像(保证代理脚本可执行、无需额外依赖); - 用
parse(proxyCommand, process.env)把命令字符串安全地分词成参数数组后spawn,源码注释明确这是"防止命令注入"(与 Seatbelt 路径的shell: true不同,容器路径是shell: false); - 就绪探测同样是轮询
http://localhost:8877; - 最后
network connect gemini-cli-sandbox gemini-cli-sandbox-proxy把代理容器挂入沙箱内部网络(兼容不支持多个--network参数的旧版 Docker); - 退出清理通过
docker rm -f gemini-cli-sandbox-proxy完成。
- 以
3.3 使用方式小结
结合文档 docs/examples/proxy-script.md 的头部注释与 docs/cli/sandbox.md 的 profile 说明,典型用法是:
# 1. 把示例脚本保存为可执行命令(如 scripts/example-proxy.js) # 2. 选择代理化 profile(Seatbelt 示例) export SEATBELT_PROFILE=strict-proxied # 3. 指定代理命令并启动带沙箱的 CLI export GEMINI_SANDBOX_PROXY_COMMAND=node scripts/example-proxy.js gemini -s -p "curl https://example.com" # 沙箱内测试:应放行要点与限制:
- 代理必须监听
:::8877(所有接口、8877 端口),CLI 的就绪探测和默认代理地址都锚定在这里; - 代理随沙箱自动启动/停止,无需手动管理进程;
- 想让沙箱内客户端(curl、npm 等)真正走代理,前提是它们遵循
HTTPS_PROXY/HTTP_PROXY环境变量——代理只约束"愿意走代理"的流量,Seatbelt*-proxiedprofile 通过策略层强制把出站收敛到localhost:8877,这是最强的组合; - 若宿主已设置
HTTPS_PROXY等变量,CLI 会沿用其值(Seatbelt 路径)或在替换localhost后注入(容器路径),默认值http://localhost:8877仅在完全未设置时生效。
4. 改造示例脚本的实战方向
以仓库示例为骨架,最常见的定制是把ALLOWED_DOMAINS换成真实业务域名,例如需要让沙箱内npm install工作的场景可以放行 npm 官方源与镜像的域名(具体域名以实际使用为准),并保持ALLOWED_PORT = '443'不变。若需要放行非 HTTPS 端口,则需同时扩展端口白名单逻辑。
调试时建议:
- 先在宿主直接运行代理命令(如
node example-proxy.js),观察[PROXY] Intercepted CONNECT request for: ...日志确认放行/拒绝分支; - 配合
DEBUG=1启动 CLI,查看 debugLogger 中的[PROXY STDERR]输出与"waiting for proxy to start ..."进度(调试模式参考 docs/cli/sandbox.md 的 Debug mode 一节); - 在沙箱内用
curl https://example.com(白名单内)与curl https://other.com(白名单外)对照验证 200 与 403 行为。
5. 相关路径索引
| 内容 | 路径 |
|---|---|
| 本文主体:示例代理脚本 | docs/examples/proxy-script.md |
| 沙箱总览与 profile 列表 | docs/cli/sandbox.md |
| 代理生命周期与双路径实现 | packages/cli/src/utils/sandbox.ts |
| 沙箱/代理网络与容器名常量 | packages/cli/src/utils/sandboxUtils.ts |
| Seatbelt 严格代理 profile(端口 8877 放行点) | packages/cli/src/utils/sandbox-macos-strict-proxied.sb |
| "Proxied networking" 官方说明 | CONTRIBUTING.md |
| 代理环境变量相关测试 | packages/cli/src/utils/sandbox.test.ts |
综上,GEMINI_SANDBOX_PROXY_COMMAND是 Gemini CLI 沙箱体系中"网络出口可审计化"的抓手:Seatbelt 策略或容器内部网络把流量强制导向:::8877,示例代理脚本再在 CONNECT 层做域名白名单过滤,两者配合使沙箱内命令的出站访问从"全放"收敛到"仅白名单域名的 443 端口",且代理的启动、就绪探测、故障联动与退出清理全部由 CLI 自动完成。
【免费下载链接】gemini-cliAn open-source AI agent that brings the power of Gemini directly into your terminal.项目地址: https://gitcode.com/GitHub_Trending/gemi/gemini-cli
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考