出口凭据注入代理(iron-proxy)

当 Hermes 把你的 agent 跑在 Docker 终端沙箱里时,那个沙箱通常持有你真实的上游 API key(OPENROUTER_API_KEY、OPENAI_API_KEY 等)。沙箱里一个被提示注入的 agent 可以 cat ~/.config/openrouter/auth.json 或 printenv | grep -i key 把它们偷走。

出口代理解决这个问题:沙箱只持有不透明的代理 token,绝不持有真实 key。沙箱所有出站流量都经宿主机上一个本地 iron-proxy 守护进程(Apache-2.0,Go)路由,它在转发请求到上游之前终结 TLS,并把代理 token 换成真实凭据。攻陷沙箱后,攻击者拿到的 token 只能在配置的可信代理边界之后使用——CA 私钥和代理端点完整性都是这个边界的一部分。如果流量能被重定向到攻击者控制的代理基础设施(例如被盗的 CA 私钥或被劫持的代理端点),token 保证就不再成立。

本版本只把出口代理接入 Docker 后端。Modal、Daytona、SSH 和 Singularity 尚未获得代理环境变量或 CA 挂载。

它是什么

  • 宿主机上的一个 iron-proxy 子进程,其锁定二进制存放在 PM 工具 store
  • 一个位于 ~/.hermes/proxy/ca.crt 的本地 CA,沙箱信任它,因此 iron-proxy 可以 MITM TLS 并重写请求头
  • 一份位于 ~/.hermes/proxy/proxy.yaml 的 proxy.yaml 配置,列出你允许的上游主机和 secrets-transform 映射
  • 一份 mappings.json,记录哪个代理 token 对应哪个真实环境变量

沙箱拿到 HTTPS_PROXY=http://host.docker.internal:9090、HTTP_PROXY=http://host.docker.internal:9091,以及标准提供商环境变量(如 OPENROUTER_API_KEY)被设为不透明代理 token。同时导出匹配的 HERMES_PROXY_TOKEN_<ENV_NAME> 别名用于诊断。现有提供商 SDK 读取惯用的环境变量名,在 Authorization 里发送代理 token,iron-proxy 的 secrets 变换会替换成从宿主机侧守护进程环境取来的真实值。

它不是什么

  • 它不是入站的 hermes proxy 命令——那个是 OAuth 聚合反向代理。命令不同(hermes egress),方向不同。
  • 它不位于你的本地终端和提供商之间——只在沙箱和提供商之间。
  • 它不为宿主机进程发起的进程内 LLM 调用重写凭据。那些继续直接使用你的 .env key。威胁模型是沙箱,不是宿主机。

快速上手

# 1. 安装 iron-proxy 二进制(锁定版本,SHA-256 校验)
hermes egress install

# 2. 运行向导:生成 CA,为你环境中每个提供商 key 铸造代理 token,
#    写入 proxy.yaml。
hermes egress setup

# 3. 启动代理守护进程
hermes egress start

# 4. 查看状态
hermes egress status

hermes egress setup 从你的环境发现提供商 key。如果你的 key 只在 ~/.hermes/.env(没有 export 到 shell),setup 会自动读取该文件——你不必先 export。

当你之后重跑 setup(新增允许列表主机、轮换 token、切换凭据源)时,它会停止正在运行的守护进程(因为配置在内存中),然后主动为你重启,让改动立即生效。在 tty 上它会询问;传 --restart 总是重启,或 --no-restart 让它保持停止。要在其他任何时候应用改动,hermes egress restart 就是那条"先停后启"的命令。

一旦运行,Docker 终端后端会自动:

  • 把 ~/.hermes/proxy/ca.crt 挂载进沙箱的 /etc/ssl/certs/hermes-egress-ca.crt
  • 设置 HTTPS_PROXY、HTTP_PROXY、REQUESTS_CA_BUNDLE、SSL_CERT_FILE、CURL_CA_BUNDLE、NODE_EXTRA_CA_CERTS,让每个常见 HTTP 运行时都走代理并信任该 CA
  • 设置 NODE_OPTIONS=--use-openssl-ca(追加到你 docker_env.NODE_OPTIONS 已有内容之后),让 Node.js 走其他 CA-bundle 变量所控制的 OpenSSL 存储——残余差距见下文 Node.js 非对称 CA 注意事项
  • 添加 --add-host=host.docker.internal:host-gateway,让沙箱在 Linux 上能访问宿主机侧代理(macOS/Windows 上 Docker Desktop 自动处理)
  • 以标准提供商环境变量名导出代理 token(例如 OPENROUTER_API_KEY),并为每个铸造的映射额外导出一个 HERMES_PROXY_TOKEN_<ENV_NAME> 诊断别名

配置

完整配置在 ~/.hermes/config.yaml 的 proxy: 段下。默认值内联注释;全部可选。

proxy:
  # 总开关。为 false 时该功能完全空转——
  # 不下载二进制、不加 docker 挂载、不启动子进程。
  enabled: false

  # 隧道监听端口。沙箱访问 http://host.docker.internal:<port>。
  tunnel_port: 9090

  # 首次使用时自动下载锁定的 iron-proxy 二进制。
  auto_install: true

  # egress 时 iron-proxy 在哪里查真实上游机密。
  #   env       —— 进程环境(默认)。代理启动时你 ~/.hermes/.env 里
  #               有什么,什么就是事实来源。
  #   bitwarden —— 每次代理重启时从 Bitwarden Secrets Manager 重新拉取。
  #               在 BW Web 应用里轮换即可传播,无需碰 .env。
  #               需要 `secrets.bitwarden.enabled: true`。
  credential_source: env

  # 为 true(默认)时,如果代理已启用但未运行,Docker 后端拒绝启动沙箱。
  # 设为 false 可在代理不可用时回退到旧的"沙箱内放真实凭据"姿态。
  enforce_on_docker: true

  # 当 `credential_source: bitwarden` 但 BWS access token /
  # project_id 缺失,或 bws 拉取对已映射提供商返回空时,
  # 守护进程默认报错(契合"我要了轮换——别静默用过时 env 值"的精神)。
  # 设为 true 可退回旧的宿主机 env 回退——适合迁移场景:
  # 你想开始切到 BW 模式但还没接好每个机密。
  allow_env_fallback: false

  # 应用于出站流量的 SSRF 拒绝列表。省略 / 留 null 则用安全默认:
  # 环回(v4 + v6)、链路本地(含 169.254.169.254 云元数据 IP)、
  # RFC1918、IPv6 ULA、IPv4-mapped-v6、CGNAT 和 RFC2544 基准范围。
  # 显式设为 `[]` 可完全退出(仅密闭测试合理)。
  upstream_deny_cidrs: null

  # 除内置默认之外额外允许的上游主机。
  # 支持通配符(`*.foo.com`)。默认覆盖 OpenRouter、
  # OpenAI、Anthropic、Google、xAI、Mistral、Groq、Together、DeepSeek
  # 和 Nous Research。
  extra_allowed_hosts: []

默认允许的上游主机

openrouter.ai           *.openrouter.ai
api.openai.com          api.anthropic.com
generativelanguage.googleapis.com
api.x.ai                api.mistral.ai
api.groq.com            api.together.xyz
api.deepseek.com        inference.nousresearch.com

如果你的 agent 需要列表之外的上游——自托管推理端点、额外的云 LLM、MCP 服务器——把它加到 proxy.extra_allowed_hosts。通配符按完整主机名匹配(*.example.com 匹配 api.example.com 和 staging.example.com,但不匹配 example.com 本身)。

默认 SSRF 拒绝 CIDR

无论允许列表如何都会应用。这些范围在网络边界被 iron-proxy 拒绝,因此通过允许列表主机名做的 DNS 重绑定攻击到不了 IMDS 或你的内网:

CIDR用途
127.0.0.0/8、::1/128环回(v4 + v6)
169.254.0.0/16、fe80::/10链路本地——含 169.254.169.254 处的 AWS / GCP / Azure IMDS
10.0.0.0/8、172.16.0.0/12、192.168.0.0/16RFC1918
fc00::/7IPv6 ULA
::ffff:0:0/96IPv4-mapped IPv6——堵住双栈 IMDS 旁路
100.64.0.0/10RFC6598 CGNAT(AWS VPC、K8s pod 网络使用)
198.18.0.0/15RFC2544 基准范围

要覆盖:把 proxy.upstream_deny_cidrs 设为你自己的列表。要完全退出(例如密闭测试需要访问环回上游):设为空列表 []。

绑定策略

代理绝不绑定 0.0.0.0。默认绑定因平台而异,因为 iron-proxy v0.39 每个守护进程只支持单次绑定:

  • Linux: docker 网桥网关(默认 172.17.0.1:<tunnel_port>)。容器通过 host.docker.internal 访问代理,--add-host=host.docker.internal:host-gateway 恰好把它解析到这个网桥网关 IP——纯环回绑定会从沙箱内不可达。网桥 IP 是宿主机 docker0 接口上的地址,因此不暴露到 LAN;默认网桥网络上的其他容器确实能到达它,但请求仍需要一个铸造的代理 token 和一个在允许列表中的上游。如果没检测到 docker 网桥(docker 未安装/未运行),绑定回退到环回并给出警告。
  • macOS / Windows Docker Desktop: 环回(127.0.0.1:<tunnel_port>)。Desktop 的 VPNkit 把 host.docker.internal 路由到宿主机,因此从容器可到达环回,且这是暴露面最小的选择。

LAN 上的对手即使有泄露的代理 token 也用不了代理——两种绑定都从外网不可达。

我们还固定 metrics.listen: 127.0.0.1:0,让守护进程内置的 metrics 服务器拿到一个临时环回端口,而非默认的 :9090——否则它会和 tunnel_port: 9090 抢同一个 socket,守护进程会以"address already in use"拒绝启动。注意 :0 临时端口每次启动随机,且不外露,因此该固定实际上等于关闭了 metrics。

如果 PATH 上更早的恶意 ip 垫片曾能注入一个非公 IPv4 作为网桥地址(0.0.0.0、公网地址、组播、链路本地等),环回回退仍然适用——我们绝不绑定任何无法通过 ipaddress.IPv4Address + is_* 校验的地址。

覆盖的认证方案

secrets 变换在代理 token 出现的任何匹配位置替换它——它匹配的不止 Authorization: Bearer:

提供商环境变量替换到哪里
OpenRouter、OpenAI、Groq、Together、DeepSeek、Mistral、xAI、Nous*_API_KEYAuthorization 请求头
Anthropic 原生ANTHROPIC_API_KEYx-api-key + Authorization
Azure OpenAIAZURE_OPENAI_API_KEYapi-key + Authorization(*.openai.azure.com、*.cognitiveservices.azure.com、*.services.ai.azure.com)
Google AI Studio(Gemini)GEMINI_API_KEY / GOOGLE_API_KEYx-goog-api-key 请求头或 ?key= 查询参数

GEMINI_API_KEY 和 GOOGLE_API_KEY 被视为同一个凭据:铸造一个代理 token,以两个名字注入沙箱,宿主机环境中任一名都能满足发现。

未覆盖的提供商

涉及请求签名或 SDK 铸造 OAuth 的认证方案无法靠静态请求头替换——如果它们的环境变量在场,沙箱就持有这些提供商的真实凭据,出口隔离保证对它们不完整:

环境变量提供商原因
AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEYAWS Bedrock / SageMakerSigV4 签名请求
GOOGLE_APPLICATION_CREDENTIALSGCP Vertex AI从服务账号文件铸造的 OAuth

这些环境变量在大多数开发者笔记本上因无关工具而存在(terraform、gcloud、aws CLI、ECR push)。它们在向导和 hermes egress status 中以警告形式出现,但绝不阻止代理启动。如果你不从沙箱用这些提供商,unset 这些变量即可消除警告。

Bitwarden 集成

如果你已经通过 hermes secrets bitwarden setup 使用 Bitwarden Secrets Manager,出口代理可以从那里拉取真实凭据,而非 os.environ:

hermes egress setup --from-bitwarden

这会设置 proxy.credential_source: bitwarden,并从你的 BW 项目发现提供商环境变量名。

轮换语义

当 credential_source: bitwarden 时,iron-proxy 守护进程每次启动都通过 bws secret list <project_id> 从 BWS 重新拉取机密。因此轮换流程是:

  1. 在 Bitwarden Web 应用轮换一个 key。
  2. 在宿主机 hermes egress stop && hermes egress start。
  3. 之后启动的沙箱把代理 token 换成新值。

无需编辑 .env。无需在宿主机重启 Hermes。代理守护进程是唯一触碰新值的东西——你的宿主机进程和 os.environ 不动。

启动时大声失败

当 credential_source: bitwarden 时,hermes egress start 在向导层预检,_build_proxy_subprocess_env 还在守护进程层复检:

  • BWS access token 环境变量未设置 → 拒绝启动,提示 unset 后重跑,或 hermes egress setup --no-bitwarden 切回 env 模式
  • secrets.bitwarden.project_id 为空 → 拒绝启动,提示运行 hermes secrets bitwarden setup
  • bws secret list 对一个或多个已映射提供商返回空 → 拒绝启动,列出缺失的名字

这是有意为之。在 BW 模式下回退到宿主机 env,恰恰会重新引入 BW 路径要打败的那个陈旧 bug(运维选 BW 就是为了轮换保证;静默回退破坏了该保证)。

proxy.allow_env_fallback: true 配置标志为迁移场景退回旧的"BWS 不可达时静默回退宿主机 env"行为。当你把机密逐个搬进 BW、想让守护进程用任何可用值启动时用它。

切换凭据源

从到命令
envbitwardenhermes egress setup --from-bitwarden
bitwardenenvhermes egress setup --no-bitwarden

不带任一标志重跑 hermes egress setup 会保留现有 credential_source——向导拒绝静默把你降回 env。这很重要:一旦配好 bitwarden 模式,你要的就是轮换保证;要改回去必须明确说"我又要 env 了"。

斜杠命令

CLI 子命令树:

hermes egress install                  # 下载锁定的 iron-proxy 二进制
hermes egress install --force          # 检查并修复托管副本

hermes egress setup                    # 交互式向导
hermes egress setup --tunnel-port N    # 覆盖隧道监听端口
hermes egress setup --from-bitwarden   # 用 BWS 作为凭据源(大声失败)
hermes egress setup --no-bitwarden     # 显式切回 env 模式
hermes egress setup --rotate-tokens    # 为每个提供商铸造新 token
                                       #   (默认保留现有)

hermes egress start                    # 派生托管代理守护进程
hermes egress stop                     # SIGTERM(5 秒宽限后 SIGKILL)
hermes egress restart                  # 停(若在运行)后启——上游 SECRETS
                                       #   变化时需要(轮换、新提供商)
hermes egress reload                   # 通过管理 API 热重载 proxy.yaml 规则集
                                       #   ——不重启、不断连
                                       #   (允许列表 / 映射编辑)

hermes egress status                   # 二进制 + 配置 + pid + 监听状态 + 映射
hermes egress status --show-tokens     # 完整打印代理 token
                                       #   (默认:脱敏的前后缀)

hermes egress disable                  # 翻 proxy.enabled = false
                                       #   (不停正在运行的代理)

hermes egress config                   # 打印 proxy.yaml 路径,用于调试

Token 轮换

默认情况下,hermes egress setup 对已有 token 的提供商保留其代理 token。新增一个提供商只为新的那个铸造新 token;现有 token 不变。这样你重跑向导时不会让正在运行的沙箱 401。

--rotate-tokens 会滚动所有 token:

hermes egress setup --rotate-tokens

当已有 token 且 stdin 是 tty 时,向导会提示确认:

⚠  --rotate-tokens 会使每个运行中的 Hermes 沙箱里的代理 token 失效。
   它们在重启前会开始对上游返回 401。
输入 'rotate' 确认:

非 tty 调用(CI、脚本)跳过提示——该标志被视为有意为之。任何覆盖之前,当前 mappings.json 会被复制到一个带时间戳的兄弟文件,以便手动恢复:

backup: ~/.hermes/proxy/mappings.json.rotated-20260524T143012

hermes egress setup 在重写配置或 token 映射时会停止正在运行的守护进程,因为守护进程把旧 YAML 留在内存里。--rotate-tokens 之后:

hermes egress start

已在运行的容器持有旧 token,需要重启才能拿到新的。新的持久 Docker 容器带有 egress-posture 标签,因此 Hermes 不会为新会话复用 egress 前或轮换前的容器。

状态目录布局

PM 拥有托管二进制。Hermes 在检查 PM 选择之前优先采用 PATH 上的 iron-proxy 可执行文件。两者都没有时,auto_install 按 PM 的懒安装策略请求锁定包。显式安装会检查并修复托管条目,而不对有效文件强制重新下载。哈希和签名校验见 PM 安全工具。

守护进程配置、凭据和日志保持按 profile 作用域,位于 $HERMES_HOME/proxy/(默认 ~/.hermes/proxy/):

路径模式用途
~/.hermes/proxy/(目录)0o700仅你属主可遍历
ca.crt0o644分发到沙箱的公开 CA 证书
ca.key0o600CA 签名密钥——永不离开宿主机
proxy.yaml0o600iron-proxy 配置;每次 setup 重写
mappings.json0o600沙箱代理 token → 上游环境变量
mappings.json.rotated-*0o600--rotate-tokens 创建的备份
iron-proxy.pid0o600运行中守护进程的 PID
iron-proxy.nonce0o600每次启动的 nonce,用于 PID 回收防御
iron-proxy.log0o600守护进程 stdout/stderr——v0.39 上含逐请求记录
audit.log0o600预留给未来二进制版本专用的逐请求审计流;预创建以便上游接入时隐私契约成立

CA 私钥是最敏感的文件。它从第一字节起就以 0o600 创建(无 umask 窗口 TOCTOU)并带 O_NOFOLLOW,因此同 uid 攻击者无法通过预置符号链接重定向它。pidfile、nonce 文件、守护进程日志和审计日志同样处理。

iron-proxy v0.39 上的日志

在当前锁定的二进制版本(v0.39.0)上,iron-proxy 把所有输出——守护进程级诊断和逐请求记录——写到 ~/.hermes/proxy/iron-proxy.log。v0.39 的 config.Log 结构没有单独的 audit_path 字段,因此我们无法在那里把逐请求记录路由到专用流。

我们仍以 0o600 + O_NOFOLLOW 预创建 ~/.hermes/proxy/audit.log,因为:

  1. 它为将来的版本升级保留路径:当锁定版本升到支持 log.audit_path 的版本时,逐请求记录无需运维侧重配就会开始流向那里。在此之前该文件保持 0 字节——暂勿把监控、告警或取证工具指向它。 今天一切都用 iron-proxy.log。
  2. "从第一字节起 0o600"的保证防御上游修复日:若 v0.40+ 在文件不存在时用其默认 umask 创建它。

在版本升级落地之前,把 iron-proxy.log 视为两类读者的事实来源:

  • 守护进程级事件(启动横幅、绑定错误、关闭原因、变换错误)。运维 + 故障排查。
  • 逐请求记录(CONNECT 到允许列表上游、触发 secret 替换、允许列表拒绝)。取证 + 合规。

两个文件跨重启追加。如果你在意长生命周期宿主机上的磁盘占用,用 logrotate 轮转它们。

工作原理

┌──────────────┐                ┌──────────────┐                ┌─────────────┐
│ Docker       │ CONNECT /     │ iron-proxy    │ HTTPS w/       │ OpenRouter  │
│ sandbox      ├──────────────▶│ (host:9090)   ├───────────────▶│ / OpenAI /  │
│              │ HTTP forward  │               │ real API key   │ Anthropic …  │
│ has:         │ w/ proxy tok  │ mints leaf    │                │             │
│ - proxy tok  │ in Auth hdr   │ cert from CA  │                │             │
│ - CA cert    │               │ matches token │                │             │
│ - HTTPS_PROXY│               │ swaps secret  │                │             │
└──────────────┘               └──────────────┘                └─────────────┘
                                       │
                                       │ 守护进程 + 逐请求日志(v0.39 合并)
                                       ▼
                              ~/.hermes/proxy/iron-proxy.log
                              (~/.hermes/proxy/audit.log 预留给 v0.40+ 分流流)
  1. 沙箱发起一个 HTTPS 请求,例如 POST https://openrouter.ai/v1/chat/completions,带 Authorization: Bearer hermes-proxy-openrouter-…(代理 token,不是真实 key)。
  2. 因为设了 HTTPS_PROXY,请求以 CONNECT 隧道形式发给 iron-proxy。
  3. iron-proxy 检查允许列表。openrouter.ai 被允许。
  4. iron-proxy 为 openrouter.ai 铸造一张由我们 CA 签名的叶子证书,终结 TLS 连接,检查请求。
  5. secrets 变换匹配 Authorization 请求头中的代理 token 字符串,替换为从 iron-proxy 自身环境取来的真实 OPENROUTER_API_KEY 值。
  6. 请求重新加密并转发给 OpenRouter。
  7. 在 v0.39 上请求记录到 ~/.hermes/proxy/iron-proxy.log。当锁定二进制版本支持分流流(v0.40+)时,逐请求记录将流向 ~/.hermes/proxy/audit.log,守护进程级诊断留在 iron-proxy.log。参见 iron-proxy v0.39 上的日志。

对非允许列表主机的请求(例如 https://attacker.example.com/leak?key=...)在任何字节离开宿主机前以 HTTP 403 拒绝。拒绝连同上游主机和来源沙箱记录在 iron-proxy.log。

CA 分发进沙箱

当 Docker 后端以 proxy.enabled: true 启动容器且守护进程在监听时,它给 docker run 加上这些参数:

参数用途
-v ~/.hermes/proxy/ca.crt:/etc/ssl/certs/hermes-egress-ca.crt:ro只读挂载 CA
-e HTTPS_PROXY=http://host.docker.internal:9090Python httpx / curl / go 默认传输 / Node fetch
-e HTTP_PROXY=http://host.docker.internal:9091纯 HTTP 的 curl + wget——纯 HTTP 转发监听器在 tunnel_port + 1
-e NO_PROXY=127.0.0.1,localhost,::1沙箱内环回开发服务器绕过代理
-e REQUESTS_CA_BUNDLE=…ca.crtPython requests
-e SSL_CERT_FILE=…ca.crtPython ssl 模块 / OpenSSL——替换系统存储
-e CURL_CA_BUNDLE=…ca.crtcurl——替换系统存储
-e NODE_EXTRA_CA_CERTS=…ca.crtNode.js——追加到系统存储
-e NODE_OPTIONS="<your value> --use-openssl-ca"Node.js——走 OpenSSL 存储(追加;保留你的 --max-old-space-size 等)
-e HERMES_EGRESS_PROXY=1agent 可读取的哨兵,表明它有代理感知
-e OPENROUTER_API_KEY=<proxy-token>标准提供商环境名接收代理 token,让现有 SDK 继续工作
-e HERMES_PROXY_TOKEN_<NAME>=…每个映射的诊断别名;与标准提供商环境变量同值
--add-host=host.docker.internal:host-gateway仅 Linux;Docker Desktop 自动映射

Node.js 非对称 CA 注意事项

REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE 在沙箱内替换系统 CA 存储。NODE_EXTRA_CA_CERTS 向其追加。沙箱内的 Node.js 进程原则上可以通过打开原始 net.Socket 并自己做 TLS 握手来绕过代理——系统 CA 存储仍信任真实上游证书,因此请求会成功,而 Python / curl 会校验失败。

NODE_OPTIONS=--use-openssl-ca 追加到你 docker_env.NODE_OPTIONS 已有内容之后。这强制 Node 走 SSL_CERT_FILE 所控制的 OpenSSL 存储,收窄了不对称性。它不覆盖显式向 tls.connect() 或 https.request() 传入自己 ca 选项的代码,但堵住了简单情形。

这是已知的 v1 限制。上游解决见 github.com/ironsh/iron-proxy/issues;在此之前,不要在一个你依赖出口隔离的沙箱里运行打开原始 socket 的不可信 Node 代码。

docker_env 冲突

如果你在 docker_env: 配置块里设置了代理控制环境变量(罕见但可能),当 enforce_on_docker: true 时 Hermes 会拒绝启动沙箱。这包括两类:

  • 出口控制变量:HTTPS_PROXY、HTTP_PROXY、NO_PROXY、REQUESTS_CA_BUNDLE、SSL_CERT_FILE、CURL_CA_BUNDLE、NODE_EXTRA_CA_CERTS
  • 真实提供商环境变量:mappings.json 中的每个名字(如 OPENROUTER_API_KEY、OPENAI_API_KEY)

错误示例:

config.yaml 中的 docker_env 覆盖了出口代理变量
['HTTPS_PROXY', 'OPENROUTER_API_KEY'];enforce_on_docker 已启用。
请从 docker_env 移除这些键,或禁用 enforce_on_docker
以退出出口隔离。

设 enforce_on_docker: false 时,同样情况变为警告,你的 docker_env 值胜出——适合迁移或测试,但你是在明确退出隔离保证。

PID 与 nonce 防御

守护进程 pidfile 用 O_EXCL + O_NOFOLLOW + 属主检查写入。并发的 hermes egress start 调用产生两种结果之一:

  • 现有 pidfile 指向一个活跃的 iron-proxy → 第二次启动拒绝,报"another start in progress"并提示运行 hermes egress stop
  • 现有 pidfile 陈旧(守护进程崩溃)→ 第二次启动解链它并重试一次

除此之外,每次 start_proxy 都在两处植入一个新的随机 nonce:

  • 守护进程环境中的 HERMES_IRON_PROXY_NONCE=<nonce>
  • ~/.hermes/proxy/iron-proxy.nonce(pidfile 的 0o600 兄弟文件)

当 hermes egress stop(或任何其他 _pid_alive 检查)想确认一个 PID 仍指我们的守护进程——而非 iron-proxy 崩溃后被分配了同一 PID 的无关进程——它读取 /proc/<pid>/environ 查找 nonce。磁盘上的副本让这跨 CLI 调用成立(内存中的 _proxy_nonce 是每进程的,每次 hermes 调用都重置)。

nonce 检查失败时,代码回退到把 argv[0] 基名与 iron-proxy 匹配。stop_proxy 还在 SIGTERM 前捕获 /proc/<pid>/stat 的 starttime,并在 5 秒宽限窗口后复检——如果 starttime 漂移了,说明等待期间 PID 被回收,SIGKILL 被抑制并给警告。

安全模型

它防什么:

  • Docker 沙箱里被提示注入的 agent 读取 printenv / 凭据文件并外泄真实 key。
  • 沙箱里被攻陷的依赖给任意主机打电话——默认拒绝的允许列表挡住未知目的地。
  • agent 拨云元数据端点(169.254.169.254)——iron-proxy 通过 upstream_deny_cidrs 默认拒绝这些,包括 IPv4-mapped-v6 形式 ::ffff:169.254.169.254。
  • 通过允许列表主机名做 DNS 重绑定到私有 IP——拒绝 CIDR 在 connect 时检查,而非在允许列表时。
  • 同 uid 本地进程读 iron-proxy 守护进程环境扒机密——只有映射引用的环境变量名被转发,不是整个宿主机环境。
  • LAN 上有泄露沙箱代理 token 的对手花你的 API 额度——代理绑定 docker 网桥网关(Linux)或环回(Docker Desktop),绝不 0.0.0.0,因此从外网不可达。

它不防什么:

  • 被攻陷的宿主机进程。如果 agent 进程本身被攻陷,宿主机 ~/.hermes/.env 里的真实 key 照样暴露。这是面向沙箱攻陷的纵深防御,不是宿主机攻陷。
  • 可信代理边界本身的丧失。 token 交换保证假设沙箱信任挂载的 CA 证书(/etc/ssl/certs/hermes-egress-ca.crt)且流量确实到达我们的 iron-proxy。如果 CA 私钥被盗,或沙箱出口被重定向到攻击者控制的代理基础设施,中间人可以出示一张合法叶子证书,代理 token 就不再是有意义的边界(参见 MITRE ATT&CK T1588.004——获取 TLS 证书材料以实施 AiTM)。相应保护好 CA key(它是 0600、仅宿主机)和代理端点。
  • 沙箱进程用原始 socket 绕过 HTTPS_PROXY。代理拦不住不路由到它的东西。Node.js 通过 NODE_OPTIONS=--use-openssl-ca 部分缓解(见上文注意事项)。
  • 显式挂载进 Docker 的凭据文件(terminal.credential_files 或 skill 注册的挂载)。出口保护提供商环境变量;它不检查任意挂载文件。不要把真实提供商凭据挂载进一个强制 egress 的沙箱。
  • 允许列表主机的数据外泄。如果 api.openai.com 被允许,agent 可以把外泄数据嵌进发给该主机的请求体。守护进程日志捕获请求发生了,但不能阻止它。
  • 未覆盖的提供商(AWS Bedrock SigV4、GCP Vertex 服务账号 OAuth)。它们的环境变量留在沙箱里;如果你启用它们,那些凭据完全绕过代理。参见未覆盖的提供商。
  • iron-proxy 内存中的机密清零。Go 二进制在进程内存中持有换入的真实凭据;同 uid 攻击者做 core-dump 或读 /proc/<pid>/mem 就能暴露它们。不在本层范围。

失败模式

  • 二进制未安装,auto_install: true——首次 hermes egress setup 或 hermes egress start 下载它。对照上游 checksums.txt 做 SHA-256 校验。
  • 二进制未安装,auto_install: false——start 失败,给出指向手动安装的明确消息。
  • enabled: true 但代理未运行——enforce_on_docker: true(默认)时,Docker 沙箱创建拒绝启动并给出解释性错误。enforce_on_docker: false 时,回退到用真实凭据直连出站并记一条警告。
  • 端口冲突——iron-proxy 立即退出;hermes egress start 报告最后 20 行日志并不零退出。
  • 上游主机被拒——沙箱从代理收到 HTTP 403,正文说明哪个主机不被允许。agent 看到错误并报告。
  • 请求云元数据 IP(169.254.169.254)——无论允许列表如何,都被 upstream_deny_cidrs 拒绝。
  • docker_env 与某个代理控制变量冲突(enforce 开)——沙箱创建拒绝,列出冲突键的名字。
  • docker_forward_env 试图转发一个受保护的提供商 key(enforce 开)——沙箱创建拒绝;从 docker_forward_env 移除该键,或用 proxy.enforce_on_docker: false 退出。
  • docker_extra_args 覆盖代理环境/网络控制(enforce 开)——沙箱创建拒绝;用户提供的 -e HTTPS_PROXY=...、--env-file 或 --network 参数在 Hermes 生成的参数之后运行,可能绕过 egress。
  • credential_source: bitwarden 下 BWS access token 缺失——hermes egress start 拒绝,并以 --no-bitwarden 作为恢复提示。
  • iron-proxy 5 秒内未绑定——进程被杀,pidfile 解链,错误点名端口 + iron-proxy.log 尾部。
  • 并发 hermes egress start 调用——若第一次的守护进程已起来,第二次以"another start in progress"拒绝;否则第二次解链陈旧 pidfile 继续。

故障排查

"Refusing to start: BWS_ACCESS_TOKEN is not set"

你启用了 credential_source: bitwarden 但 access token 环境变量不在你的 shell 里。要么:

export BWS_ACCESS_TOKEN=…   # 一次性
hermes egress start

要么把它挪进 ~/.hermes/.env。或切回 env 模式:

hermes egress setup --no-bitwarden

"iron-proxy exited immediately"

看 ~/.hermes/proxy/iron-proxy.log 最后 20 行。常见原因:

  • 端口已被占用 → 改 proxy.tunnel_port 或杀掉占用 9090 的进程
  • proxy.yaml 无效 → 运行 hermes egress setup 重新生成
  • CA 证书/密钥权限不对 → chmod 0o600 ~/.hermes/proxy/ca.key

"iron-proxy did not bind <bind-host>:9090 within 5s"

守护进程起来了但从未绑定监听器。通常意味着二进制卡住或启动时在做重活。查 ~/.hermes/proxy/iron-proxy.log。孤儿进程自动被杀,pidfile 清理,你直接重试 hermes egress start 即可。

沙箱连代理超时(Linux)

容器把 host.docker.internal 解析到 docker 网桥网关,代理绑在那里,但宿主机防火墙(常见为默认拒绝 INPUT 的 ufw)在 docker0 上丢弃容器→宿主机流量。从容器验证:

docker run --rm --add-host host.docker.internal:host-gateway busybox \
  nc -zv -w 3 host.docker.internal 9090

如果它超时而 hermes egress status 显示 listening,在防火墙里放行网桥子网,例如 ufw:

sudo ufw allow in on docker0 to any port 9090 proto tcp
sudo ufw allow in on docker0 to any port 9091 proto tcp

(9091 = tunnel_port + 1 上的纯 HTTP 转发监听器。)

沙箱从代理看到 HTTP 403

沙箱内的 agent 试图访问不在 proxy.extra_allowed_hosts 里的主机。403 正文说明是哪个主机。要放行它,加到你的配置:

proxy:
  extra_allowed_hosts:
    - api.example.com
    - "*.staging.example.com"

然后 hermes egress setup(重新生成 proxy.yaml)和 hermes egress stop && hermes egress start。

沙箱看到 SSL 校验错误

要么 CA 没挂载进沙箱(罕见;proxy.enabled: true 时 docker 后端自动做),要么你镜像的 HTTP 客户端从非标准环境变量读取。

# 沙箱内:
cat /etc/ssl/certs/hermes-egress-ca.crt | head -1
# 应打印:-----BEGIN CERTIFICATE-----
env | grep -E "^(REQUESTS|CURL|SSL|NODE).*CA"
# 应列出全部四个 CA-bundle 环境变量,指向 /etc/ssl/certs/hermes-egress-ca.crt

如果证书不在,确认 proxy.enabled: true 且 hermes egress status 显示 Listening yes。如果环境变量缺失,沙箱镜像可能跑了一个剥离它们的 entrypoint——检查你的 docker_env 配置。

沙箱从上游看到 HTTP 401

两个常见原因:

  1. 重跑 setup 时 token 被冲掉。 你运行了 hermes egress setup --rotate-tokens(或以其他方式轮换 token),而运行中的沙箱仍持有旧 token。重启沙箱。
  2. Bitwarden 刷新静默失败。 新的大声失败行为下不应发生,但如果你设了 proxy.allow_env_fallback: true,守护进程可能带着陈旧 env 值启动。检查守护进程环境(/proc/<iron-proxy-pid>/environ)里预期的 OPENROUTER_API_KEY 等。

父进程死后出现 "Address in use"

父 Hermes 进程在 hermes egress start 期间死掉(监听探测时 Ctrl-C、OOM、panic)。新的修复逻辑在 Popen 后立即写 pidfile,因此孤儿可恢复:

hermes egress stop   # 通过 pidfile 找到孤儿,杀掉它
hermes egress start

如果 hermes egress stop 说 "iron-proxy was not running" 但你在 ps 里还能看到守护进程,pidfile 失步了。手动恢复:

pkill -TERM iron-proxy
rm -f ~/.hermes/proxy/iron-proxy.pid ~/.hermes/proxy/iron-proxy.nonce
hermes egress start

检视逐请求行为

在锁定二进制版本(v0.39)上,守护进程级事件和逐请求记录都落在 ~/.hermes/proxy/iron-proxy.log。格式是行分隔 JSON。grep 特定上游:

grep '"upstream":"openrouter.ai"' ~/.hermes/proxy/iron-proxy.log | tail -20

或实时观察:

tail -f ~/.hermes/proxy/iron-proxy.log | jq

当锁定版本升到 v0.40+(新增 log.audit_path)时,逐请求记录会移到 ~/.hermes/proxy/audit.log,iron-proxy.log 只保留守护进程级事件。在那之前,audit.log 是空占位(以 0o600 预创建,未来守护进程继承紧权限)——今天把 logrotate / 监控工具接到 iron-proxy.log,并计划在版本升级后加 audit.log。

限制(v1)

  • 仅 Docker 后端。Modal、Daytona 和 SSH 接入会在单独的 PR 中跟进。
  • 用签名认证的提供商(AWS SigV4、GCP 服务账号 OAuth)完全绕过代理——参见未覆盖的提供商。Header-token 提供商(bearer、x-api-key、api-key、x-goog-api-key)全部覆盖。
  • 上游无原生 Windows 二进制。在 Linux / macOS / WSL 上运行。
  • CA 首次生成时是 10 年自签名证书。轮换需要手工 openssl genrsa ...(或等后续加 hermes egress rotate-ca)。
  • 重跑 setup 会在重写配置或映射后停止运行中的守护进程;token 轮换后重启(或仅规则集改动用 hermes egress reload)并重启已在运行的沙箱。
  • iron-proxy 内存机密清零由上游控制。有 /proc/<pid>/mem 读权限的同 uid 攻击者能从守护进程内存读出换入的机密。
  • iron-proxy v0.39 每个守护进程只支持单次绑定(Linux 上绑 docker 网桥网关,Docker Desktop 上环回),并把守护进程 + 逐请求记录合并到单一日志流。上游加上 proxy.http_listens(复数)和 log.audit_path 后,版本升级即可接入多绑定和专用审计流。

另见