出口凭据注入代理(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 调用重写凭据。那些继续直接使用你的
.envkey。威胁模型是沙箱,不是宿主机。
快速上手
# 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/16 | RFC1918 |
fc00::/7 | IPv6 ULA |
::ffff:0:0/96 | IPv4-mapped IPv6——堵住双栈 IMDS 旁路 |
100.64.0.0/10 | RFC6598 CGNAT(AWS VPC、K8s pod 网络使用) |
198.18.0.0/15 | RFC2544 基准范围 |
要覆盖:把 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_KEY | Authorization 请求头 |
| Anthropic 原生 | ANTHROPIC_API_KEY | x-api-key + Authorization |
| Azure OpenAI | AZURE_OPENAI_API_KEY | api-key + Authorization(*.openai.azure.com、*.cognitiveservices.azure.com、*.services.ai.azure.com) |
| Google AI Studio(Gemini) | GEMINI_API_KEY / GOOGLE_API_KEY | x-goog-api-key 请求头或 ?key= 查询参数 |
GEMINI_API_KEY 和 GOOGLE_API_KEY 被视为同一个凭据:铸造一个代理 token,以两个名字注入沙箱,宿主机环境中任一名都能满足发现。
未覆盖的提供商
涉及请求签名或 SDK 铸造 OAuth 的认证方案无法靠静态请求头替换——如果它们的环境变量在场,沙箱就持有这些提供商的真实凭据,出口隔离保证对它们不完整:
| 环境变量 | 提供商 | 原因 |
|---|---|---|
AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY | AWS Bedrock / SageMaker | SigV4 签名请求 |
GOOGLE_APPLICATION_CREDENTIALS | GCP 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 重新拉取机密。因此轮换流程是:
- 在 Bitwarden Web 应用轮换一个 key。
- 在宿主机
hermes egress stop && hermes egress start。 - 之后启动的沙箱把代理 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 setupbws secret list对一个或多个已映射提供商返回空 → 拒绝启动,列出缺失的名字
这是有意为之。在 BW 模式下回退到宿主机 env,恰恰会重新引入 BW 路径要打败的那个陈旧 bug(运维选 BW 就是为了轮换保证;静默回退破坏了该保证)。
proxy.allow_env_fallback: true 配置标志为迁移场景退回旧的"BWS 不可达时静默回退宿主机 env"行为。当你把机密逐个搬进 BW、想让守护进程用任何可用值启动时用它。
切换凭据源
| 从 | 到 | 命令 |
|---|---|---|
| env | bitwarden | hermes egress setup --from-bitwarden |
| bitwarden | env | hermes 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.crt | 0o644 | 分发到沙箱的公开 CA 证书 |
ca.key | 0o600 | CA 签名密钥——永不离开宿主机 |
proxy.yaml | 0o600 | iron-proxy 配置;每次 setup 重写 |
mappings.json | 0o600 | 沙箱代理 token → 上游环境变量 |
mappings.json.rotated-* | 0o600 | --rotate-tokens 创建的备份 |
iron-proxy.pid | 0o600 | 运行中守护进程的 PID |
iron-proxy.nonce | 0o600 | 每次启动的 nonce,用于 PID 回收防御 |
iron-proxy.log | 0o600 | 守护进程 stdout/stderr——v0.39 上含逐请求记录 |
audit.log | 0o600 | 预留给未来二进制版本专用的逐请求审计流;预创建以便上游接入时隐私契约成立 |
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,因为:
- 它为将来的版本升级保留路径:当锁定版本升到支持
log.audit_path的版本时,逐请求记录无需运维侧重配就会开始流向那里。在此之前该文件保持 0 字节——暂勿把监控、告警或取证工具指向它。 今天一切都用iron-proxy.log。 - "从第一字节起 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+ 分流流)
- 沙箱发起一个 HTTPS 请求,例如
POST https://openrouter.ai/v1/chat/completions,带Authorization: Bearer hermes-proxy-openrouter-…(代理 token,不是真实 key)。 - 因为设了
HTTPS_PROXY,请求以 CONNECT 隧道形式发给 iron-proxy。 - iron-proxy 检查允许列表。
openrouter.ai被允许。 - iron-proxy 为
openrouter.ai铸造一张由我们 CA 签名的叶子证书,终结 TLS 连接,检查请求。 secrets变换匹配Authorization请求头中的代理 token 字符串,替换为从 iron-proxy 自身环境取来的真实OPENROUTER_API_KEY值。- 请求重新加密并转发给 OpenRouter。
- 在 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:9090 | Python 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.crt | Python requests |
-e SSL_CERT_FILE=…ca.crt | Python ssl 模块 / OpenSSL——替换系统存储 |
-e CURL_CA_BUNDLE=…ca.crt | curl——替换系统存储 |
-e NODE_EXTRA_CA_CERTS=…ca.crt | Node.js——追加到系统存储 |
-e NODE_OPTIONS="<your value> --use-openssl-ca" | Node.js——走 OpenSSL 存储(追加;保留你的 --max-old-space-size 等) |
-e HERMES_EGRESS_PROXY=1 | agent 可读取的哨兵,表明它有代理感知 |
-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
两个常见原因:
- 重跑 setup 时 token 被冲掉。 你运行了
hermes egress setup --rotate-tokens(或以其他方式轮换 token),而运行中的沙箱仍持有旧 token。重启沙箱。 - 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后,版本升级即可接入多绑定和专用审计流。
另见
- 上游项目:github.com/ironsh/iron-proxy
- 上游文档:docs.iron.sh
- Bitwarden 集成:
hermes secrets bitwarden - Hermes Docker 终端后端:Docker
- 开发者/贡献者参考:出口代理内部机制