Documentação oficial desta página · v0.20.6
Proxy de saída por dentro
Esta página cobre a arquitetura do firewall de injeção de credenciais na saída (hermes egress / iron-proxy) do ponto de vista de quem contribui com o código ou escreve plugins. A documentação de instalação e uso para o usuário final está em Proxy de saída.
O modelo de ameaças e o desenho geral estão resumidos na página do usuário; esta aqui trata de como tudo está ligado, onde mora o código relevante para segurança e quais invariantes você precisa preservar se mexer nele.
Organização dos módulos
agent/proxy_sources/iron_proxy.py Núcleo: instalação do binário, geração da CA,
montagem da configuração, ciclo de vida do
subprocesso, E/S de mapeamentos, defesa de
PID/nonce. Superfície de funções puras
sempre que possível.
hermes_cli/proxy_cli.py Assistente + handlers dos comandos de barra.
`hermes egress {install,setup,start,stop,
status,disable,config}`. Liga o módulo
do núcleo ao argparse.
hermes_cli/main.py:_dispatch_egress Despachante do subparser de nível mais alto.
dest='egress_command' (de propósito
separado do subparser de OAuth de entrada
`hermes proxy`, que usa
dest='proxy_command').
hermes_cli/config.py: proxy schema O bloco `proxy:` em DEFAULT_CONFIG.
Acrescentar um ajuste significa: adicionar
aqui, adicionar uma pergunta no assistente
ou um `setdefault` em
proxy_cli.cmd_setup, e documentar na
página do guia do usuário.
tools/environments/docker.py
_egress_proxy_args_for_docker() Monta o trio volume_args / env_overrides /
host_args que o backend do Docker injeta
quando `proxy.enabled: true`.
DockerEnvironment.__init__ Lógica de mesclagem do lado do Docker:
detecção de colisão contra variáveis
críticas de saída, mesclagem por acréscimo
de NODE_OPTIONS pelo sentinela
_HERMES_EGRESS_NODE_OPTIONS_APPEND,
precedência de enforce_on_docker.
tests/test_iron_proxy.py Testes herméticos (~70). Caminho de
instalação do binário, montagem da
configuração, E/S de mapeamentos, ciclo de
vida do subprocesso, construtor de
argumentos do docker, CIDRs de negação
padrão, política de bind, TOCTOU da CA,
comportamento de ensure_audit_log etc.
tests/test_iron_proxy_cli.py Testes de unidade dos handlers da CLI (~20).
Ligação com o argparse, caminhos que falham
alto, ligação do refresh do BWS, guarda de
regressão de dest='egress_command'.
tests/test_iron_proxy_e2e.py E2E ao vivo (só com HERMES_RUN_E2E=1).
Binário real do iron-proxy, curl real,
troca de token verificada de ponta a ponta.
Ciclo de vida
hermes egress install
-> agent.proxy_sources.iron_proxy.install_iron_proxy(force=...)
Baixa o tarball fixado + checksums.txt dos GitHub Releases.
Verificação SHA-256 antes de extrair.
tarfile.extract(..., filter="data") no Python 3.12+ (PEP 706);
volta para o extract simples em Python mais antigo, com
higienização do nome do membro via _pick_tar_member.
Prepara em ~/.hermes/bin/.iron-proxy_XXXX, chmod 755, os.replace
para ~/.hermes/bin/iron-proxy (atômico).
_VERSION_CACHE.pop(target) para que uma reinstalação forçada
sonde --version de novo na chamada seguinte.
hermes egress setup [--from-bitwarden | --no-bitwarden] [--rotate-tokens]
-> proxy_cli.cmd_setup
Passo 1. find_iron_proxy(install_if_missing=False) -> instala se faltar.
Passo 2. ensure_ca_cert()
Roda openssl genrsa + req via subprocess.
Grava a chave da CA via os.open(O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600)
+ os.replace. Nunca existe em disco sob o umask padrão.
Grava o certificado da CA com 0o644 (público).
Passo 3. discover_provider_mappings() ou puxa nomes do BWS via
fetch_bitwarden_secrets() quando houver --from-bitwarden.
merge_mappings(existing=load_mappings(), discovered,
rotate=args.rotate_tokens) preserva os tokens
anteriores, a menos que --rotate-tokens seja passado.
discover_uncovered_providers() e exibe os avisos.
Passo 4. ensure_audit_log(audit_log_path) # levanta erro em OSError
build_proxy_config(...) com os padrões aplicados no ponto de chamada
(padrão de CIDRs de negação, política de bind de _default_http_listen).
write_proxy_config(cfg) # atômico via .tmp + os.replace, 0o600
write_mappings(mappings) # atômico, 0o600
Passo 5. proxy_cfg["enabled"] = True; lógica de preservação de credential_source
(NÃO rebaixe bitwarden -> env silenciosamente ao rodar de novo);
save_config(cfg).
hermes egress start
-> proxy_cli.cmd_start
Verificações prévias (caminho que recusa a partida):
- credential_source=bitwarden? -> valida antes access_token_env + project_id
-> iron_proxy.start_proxy(
refresh_secrets_from_bitwarden=...,
bitwarden_config=...,
)
existing=_read_pid(); se estiver vivo, retorna de forma idempotente.
_build_proxy_subprocess_env(...): ALLOWLIST + real_env_names mapeados,
remove HTTPS_PROXY/etc. para evitar recursão, refresh opcional do BWS
(levanta erro em valores faltando, a menos que allow_env_fallback=true).
Planta o nonce: _proxy_nonce = sha256(urandom(16)); env[NONCE_ENV] = ...
Abre log_path via O_NOFOLLOW + 0o600 + checagem de st_uid.
Popen com stdin=DEVNULL, stdout=log_fd, stderr=STDOUT,
start_new_session=True (POSIX).
Fecha o log_fd do pai no finally.
_write_pidfile_safely(pidfile, proc.pid)
O_EXCL + O_NOFOLLOW + checagem de uid + nonce persistido ao lado.
FileExistsError -> distingue vivo de obsoleto, tenta de novo uma vez se obsoleto.
Instala handlers de SIGINT/SIGTERM (só na thread principal).
Laço de sondagem (formato do-while):
while True:
if proc.poll() is not None: mostra o fim do log + apaga o pidfile + levanta erro
if _port_listening(probe_host, tunnel_port): break # probe_host = host de bind configurado
if time.time() >= deadline: break (do-while: checado DEPOIS da primeira sondagem)
time.sleep(0.1)
Se não estiver escutando na saída: _kill_and_wait(proc) + apaga o pidfile + levanta erro.
hermes egress stop
-> iron_proxy.stop_proxy
Guarda de _read_pid + _pid_alive.
starttime_before = _pid_proc_starttime(pid) # só no Linux; None nos outros
os.kill(pid, SIGTERM)
Espera até 5s por uma saída graciosa.
Depois da carência: rechecagem de starttime + _pid_alive.
Se foi reciclado (deriva de starttime OU _pid_alive False), NÃO mande SIGKILL.
Caso contrário, os.kill(pid, _KILL_SIGNAL).
_cleanup_state_files: apaga o pidfile + o nonce irmão.
Invariantes de segurança
Estas são as propriedades que sustentam tudo. Se você mexer no módulo, precisa preservá-las. Onde existe teste de regressão, ele está nomeado.
Permissões de arquivo
| Caminho | Modo | Teste |
|---|---|---|
~/.hermes/proxy/ (diretório) | 0o700 | test_proxy_state_dir_is_0o700 |
ca.key | 0o600 | test_ca_key_created_with_0o600 |
ca.crt | 0o644 | (implícito; chamada de chmod em ensure_ca_cert) |
proxy.yaml | 0o600 | (chmod depois da renomeação atômica em write_proxy_config) |
mappings.json | 0o600 | (chmod depois da renomeação atômica em write_mappings) |
iron-proxy.pid | 0o600 | (modo os.open(..., 0o600) em _write_pidfile_safely) |
iron-proxy.nonce | 0o600 | (modo os.open(..., 0o600) em _write_pidfile_safely) |
audit.log | 0o600 | test_ensure_audit_log_creates_with_0o600 |
iron-proxy.log | 0o600 | (os.open(..., 0o600) + fchmod) |
Todo caminho de escrita usa os.open(O_WRONLY | O_CREAT | O_NOFOLLOW, 0o600) + checagem de os.fstat().st_uid. shutil.copy2 + os.chmod é proibido, porque abre uma janela com o umask padrão.
Minimização do ambiente do subprocesso
_build_proxy_subprocess_env NÃO PODE usar os.environ.copy(). A lista de permissões é _PROXY_SUBPROCESS_ENV_ALLOWLIST (PATH, HOME, locale etc.), mais os nomes de variável referenciados por load_mappings(). Todo o resto fica no host.
Regressão: test_subprocess_env_strips_unrelated_secrets, test_subprocess_env_strips_proxy_recursion_vars, test_subprocess_env_keeps_infrastructure_vars.
Política de bind
_default_http_listen devolve uma lista de um elemento só: no Linux, o IP do gateway da ponte do docker (os contêineres alcançam o proxy por host.docker.internal:host-gateway, que resolve para o gateway da ponte — um bind em loopback fica inalcançável de dentro dos contêineres ali); no Docker Desktop de macOS/Windows, loopback (o VPNkit roteia host.docker.internal para o host). Linux sem uma ponte docker0 detectável cai em loopback, com um aviso. Nunca 0.0.0.0, nunca :PORT (INADDR_ANY).
_detect_docker_bridge_ip valida com ipaddress.IPv4Address e rejeita is_unspecified / is_loopback / is_multicast / is_reserved / is_link_local / is_global. Um ip hostil plantado no PATH não consegue injetar 0.0.0.0.
Restrição de esquema da v0.39 e papéis dos listeners (verificado ao vivo contra o binário): a struct config.Proxy do binário só tem campos de listener no singular — não existe uma lista http_listens (no plural). O tunnel_listen é o listener de CONNECT + MITM (onde bate o tráfego de HTTPS_PROXY); o http_listen só trata encaminhamentos de HTTP puro em forma absoluta (um CONNECT enviado a ele é repassado adiante como requisição comum e devolve 400). Por isso o build_proxy_config liga o tunnel_listen na tunnel_port e o http_listen na tunnel_port + 1, ambos no host de bind da plataforma. O backend do Docker aponta HTTPS_PROXY para a tunnel_port e HTTP_PROXY para a tunnel_port + 1.
As sondagens de vivacidade (laço de sondagem do start_proxy, get_status) leem o host de bind configurado com _read_http_listen_from_config() e sondam AQUELE host — uma sondagem fixa em loopback daria como morto um daemon saudável ligado à ponte.
Regressão: test_default_bind_is_loopback_not_zero_zero (garante que não há INADDR_ANY E que http_listens NÃO está no yaml renderizado), test_default_bind_uses_docker_bridge_on_linux, test_default_bind_falls_back_to_loopback_without_bridge, test_default_bind_is_loopback_on_macos, test_detect_docker_bridge_ip_rejects_dangerous (parametrizado sobre 8 entradas de ataque).
Colisão na porta de métricas
O metrics.listen tem como padrão :9090 no iron-proxy v0.39 — a MESMA porta do tunnel_port: 9090 padrão do Hermes. O build_proxy_config PRECISA fixar explicitamente metrics.listen: 127.0.0.1:0, para que a ligação de métricas fique em uma porta efêmera de loopback que nunca colida com o listener do proxy, qualquer que seja a tunnel_port escolhida pelo operador.
Regressão: test_metrics_listener_pinned_to_loopback_ephemeral.
CIDRs de negação padrão
O _DEFAULT_UPSTREAM_DENY_CIDRS cobre loopback (v4 + v6), link-local (inclusive o IMDS em 169.254.169.254 e a forma IPv4 mapeada em v6), RFC1918, ULA de IPv6, CGNAT e a faixa de benchmark da RFC2544. O build_proxy_config(..., upstream_deny_cidrs=None) PRECISA emitir o padrão; só uma lista vazia explícita abre mão dele.
Regressão: test_default_deny_cidrs_present_when_unspecified, test_default_deny_includes_ipv4_mapped_v6.
Falha alta no log de auditoria
O ensure_audit_log levanta RuntimeError em qualquer OSError. Na v0.39 fixada, o daemon nunca escreve esse arquivo (não há campo log.audit_path), então o cmd_setup trata a falha como AVISO (o arquivo não sustenta nada até a versão subir) e qualifica a linha de sucesso como "reservada". Quando a versão fixada passar para uma que tenha log.audit_path, revisite: a criação prévia passa a sustentar a garantia de 0o600 desde o primeiro byte, e o assistente deve voltar a falhar alto.
Restrição de esquema da v0.39: log.audit_path NÃO é um campo da struct config.Log do iron-proxy v0.39, então o build_proxy_config aceita o kwarg audit_log mas NÃO o emite no yaml renderizado. Na v0.39, os registros por requisição caem no iron-proxy.log, junto com os eventos de nível de daemon. O arquivo audit.log continua sendo criado antes com 0o600 e O_NOFOLLOW, para que o contrato de privacidade se mantenha quando a versão fixada subir para uma que suporte o fluxo separado.
Regressão: test_ensure_audit_log_raises_on_immutable_parent, test_audit_log_kwarg_does_not_inject_audit_path_v039.
Falha alta no modo Bitwarden
Quando credential_source: bitwarden E proxy.allow_env_fallback: false (o padrão):
- Variável de ambiente do token de acesso faltando -> o
cmd_startrecusa. project_idfaltando -> ocmd_startrecusa.bws secret listnão devolve valores para um ou mais provedores mapeados -> o_build_proxy_subprocess_envlevanta erro.
Cair no ambiente do host no modo BW reintroduz exatamente o bug de credencial desatualizada que o caminho do BW existe para derrotar.
Regressão: test_cmd_start_refuses_when_bitwarden_token_missing (camada da CLI); asserções de modo estrito em _build_proxy_subprocess_env (camada do daemon).
Detecção de colisão em docker_env
Quando enforce_on_docker: true, sobrescritas de docker_env em qualquer uma das variáveis que controlam a saída (HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS etc.) OU em qualquer real_env_name mapeado (OPENROUTER_API_KEY etc.) levantam RuntimeError ANTES de o contêiner subir.
Regressão: test_docker_env_collision_with_proxy_raises_when_enforce.
Defesa contra reciclagem de PID
O _pid_alive PRECISA consultar ou o _proxy_nonce em memória (caso do mesmo processo) OU o iron-proxy.nonce em disco (caso entre execuções da CLI) antes de confiar numa correspondência de basename de argv[0]. O stop_proxy PRECISA rechecar o starttime em /proc/<pid>/stat antes do SIGKILL e suprimir o sinal se houver deriva de starttime.
Regressão: test_stop_proxy_suppresses_sigkill_on_pid_recycle, test_pid_proc_starttime_parses_comm_with_parens, test_persisted_nonce_roundtrip.
Preservação de tokens ao refazer o setup
O merge_mappings(existing, discovered, rotate=False) PRECISA devolver os tokens anteriores para os provedores que coincidem. Rodar hermes egress setup de novo não pode derrubar sandboxes em execução com um 401 silencioso. O --rotate-tokens é a adesão explícita.
Regressão: test_merge_mappings_preserves_existing_tokens, test_merge_mappings_rotate_mints_fresh_tokens.
Preservação de credential_source
O cmd_setup NÃO PODE rebaixar credential_source: bitwarden para env ao rodar de novo sem a flag explícita --no-bitwarden. Rodar hermes egress setup (sem flag) preserva o que estava configurado antes.
Testado pelo fluxo do cmd_setup nos testes da CLI (o caminho de preservação do bitwarden é exercitado quando um --from-bitwarden é seguido de um setup simples rodado de novo).
Pontos de extensão
Acrescentar um provedor novo de token bearer
O _BEARER_PROVIDERS em iron_proxy.py mapeia nome de variável de ambiente -> tupla de hosts upstream. Acrescentar uma entrada a torna descobrível pelo discover_provider_mappings(); o assistente cunha um token para ela automaticamente quando a variável de ambiente existe.
_BEARER_PROVIDERS: Dict[str, Tuple[str, ...]] = {
...,
"MY_PROVIDER_API_KEY": ("api.myprovider.com",),
}
Atualize também o _DEFAULT_ALLOWED_HOSTS, para que o proxy libere o upstream por padrão. Rode test_discover_provider_mappings_* para confirmar.
Acrescentar um provedor novo de token em cabeçalho (família x-api-key)
Se o provedor autentica com um cabeçalho estático que NÃO é o Authorization (como o x-api-key da Anthropic, o api-key da Azure ou o x-goog-api-key do Gemini), acrescente-o ao _HEADER_AUTH_PROVIDERS — o secrets.replace.match_headers do iron-proxy mira nomes de cabeçalho arbitrários, então esses são provedores de troca de primeira classe:
_HEADER_AUTH_PROVIDERS: Dict[str, Dict[str, Tuple[str, ...]]] = {
...,
"MY_PROVIDER_API_KEY": {
"hosts": ("api.myprovider.com",),
"match_headers": ("x-my-auth-header", "Authorization"),
"aliases": (),
},
}
Use aliases APENAS para nomes de variável intercambiáveis da mesma credencial (por exemplo, GOOGLE_API_KEY para GEMINI_API_KEY) — nomes com alias se juntam em um único mapeamento, porque duas regras com require: true no mesmo host rejeitam as requisições uma da outra. Atualize também o _DEFAULT_ALLOWED_HOSTS.
Acrescentar um provedor novo de autenticação por assinatura (sem cobertura)
Se o provedor usa SigV4, OAuth cunhado pelo SDK ou assinatura de requisição, uma troca estática de cabeçalho não dá conta. Acrescente a variável de ambiente ao _NON_BEARER_PROVIDERS, para que o assistente e o hermes egress status avisem sobre ela:
_NON_BEARER_PROVIDERS: Tuple[str, ...] = (
...,
"MY_SIGNED_PROVIDER_ACCESS_KEY",
)
Ligar o iron-proxy a um backend que não seja o Docker
O _egress_proxy_args_for_docker é específico do Docker. Backends que queiram uma ligação parecida precisam do próprio análogo, que:
- Leia
load_config().get("proxy", {}); devolva argumentos vazios seenabledfor falso. - Chame
iron_proxy.get_status(); exponha a semântica deenforcenos caminhos de falha deconfigured/pid/listening/ca_cert_path. - Chame
iron_proxy.load_mappings(); recuse montar se estiver vazio Eenforce_on_docker: true. - Defina as sete variáveis de ambiente (HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE, SSL_CERT_FILE, CURL_CA_BUNDLE, NODE_EXTRA_CA_CERTS, HERMES_EGRESS_PROXY) e as variáveis
HERMES_PROXY_TOKEN_<NAME>de cada mapeamento. - Distribua o certificado da CA para dentro da sandbox, em um caminho em que o runtime confie (normalmente
/etc/ssl/certs/hermes-egress-ca.crt). - Implemente detecção de colisão contra a configuração de ambiente específica daquele backend feita pelo usuário.
A implementação do Docker tem cerca de 150 linhas; espere um volume parecido para Modal / Daytona / SSH.
Assinar os eventos de auditoria por requisição
Na v0.39 atualmente fixada, o iron-proxy escreve JSON delimitado por linha em ~/.hermes/proxy/iron-proxy.log (registros de daemon e de requisição combinados; veja "Logging on iron-proxy v0.39" no guia do usuário). Um plugin ou observador externo pode acompanhar esse arquivo e reagir a negações da lista de permissões, trocas de segredo ou erros do upstream. Quando a versão fixada subir para uma que suporte log.audit_path, o fluxo por requisição passa para o audit.log e os observadores ligados àquele caminho entram em ação sem ação do operador. O esquema está documentado em docs.iron.sh/audit (link).
Testes
# Suíte hermética (sem rede, sem binário de verdade)
scripts/run_tests.sh tests/test_iron_proxy.py tests/test_iron_proxy_cli.py
# E2E ao vivo (binário de verdade, curl de verdade, túnel CONNECT de verdade)
HERMES_RUN_E2E=1 scripts/run_tests.sh tests/test_iron_proxy_e2e.py
# Teste de fumaça ao vivo em PTY contra o `hermes egress`
HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress --help
HERMES_HOME=/tmp/hermes-egress-test python3 -m hermes_cli.main egress setup --help
A CLI usa argparse, então --help é uma boa primeira sondagem para "será que a minha flag nova registrou direito".
Veja também
- Instalação e solução de problemas para o usuário: Proxy de saída
- O backend do Docker por dentro: Docker
- Integração com o Bitwarden Secrets Manager:
hermes secrets bitwarden - Referência de comandos da CLI:
hermes egress - Variáveis de ambiente injetadas na sandbox: Proxy de saída (injetado na sandbox)