Injeção de Credenciais no Google AX: Hands-On com Claude Code e GitHub
Por Fabio Douek
Ir para seção
- Visão Geral
- Arquitetura
- Setup
- Passo 1: A imagem da task
- Passo 2: Secrets, e quem pode lê-los
- Passo 3: Fazendo a task confiar no gateway
- Passo 4: Uma egress policy por task
- Testes
- Teste 1: Como a injeção se comporta (sem credenciais reais)
- Teste 2: Claude Code com uma chave placeholder
- Teste 3: git e gh sem token
- Teste 4: O Claude Code abre um pull request
- Teste 5: Tentando extrair as credenciais
- Teste 6: Rotação e revogação com uma task em execução
- Resumo dos resultados
- O que a injeção não resolve
- Limpeza
- Veredito
Explica (TLDR) como se eu fosse...
Imagina que o seu robô ajudante carrega uma chave de mentirinha feita de papel. Toda vez que ele chega perto do pote de biscoitos, um adulto que fica do lado do pote troca a chave de papel pela de verdade, abre o pote e fica com a chave de verdade.
O robô ganha os biscoitos, mas nunca segura a chave de verdade, então não tem como perder nem dar ela para ninguém. O adulto só fica do lado de três potes, e todas as outras portas da casa estão trancadas. O robô ainda pode pedir mais biscoitos do que deveria, então você continua de olho no que ele faz.
A questão é a custódia. Um agente de IA para código normalmente guarda as chaves de API que usa, então qualquer coisa que comprometa o agente pode copiá-las. Aqui, a chave da Anthropic e um token do GitHub ficam em Kubernetes Secrets, e um gateway que o agente não consegue alcançar adiciona as credenciais apenas a requests para três hosts nomeados.
A exposição residual é de uso, não de posse: o agente ainda pode agir com essas credenciais nos serviços permitidos. Restrinja o escopo de cada token, limite os hosts permitidos e mantenha a revogação com o operador do cluster, o que neste teste passou a valer já no request seguinte.
A condição é a exposição de credenciais em agentes de código rodando em sandbox. A intervenção tira a chave do sandbox por completo e faz um gateway anexá-la em trânsito. A evidência: toda tentativa de vazamento que eu rodei, como root e através do próprio agente, retornou só o placeholder.
Efeitos colaterais a observar: o agente ainda pode fazer mau uso do acesso que tem, qualquer host permitido que ecoe headers devolve o segredo, e o setup exige uma configuração que o AX ainda não faz por você. Indicado para times que já rodam o AX no próprio cluster, não para um protótipo rápido.
Existe um zumbido baixo de preocupação em entregar as suas chaves a um agente autônomo: você confia nele para fazer o trabalho, mas também sabe que um prompt ruim pode mandá-las para um lugar que você nunca imaginou. Essa preocupação raramente vai embora sozinha.
Este setup muda a relação. O agente nunca guarda as chaves, então a preocupação passa de "será que ele vaza" para "o que ele pode fazer com o acesso que tem", que é uma pergunta mais estreita e mais administrável para conviver e para discutir abertamente como time.
Pense no gateway como um roadie que guarda as chaves de afinação dos instrumentos. Os músicos recebem chaves de mentira, e conforme cada um sobe ao palco em uma das três casas do setlist, o roadie troca discretamente pela chave de verdade para o instrumento soar certo.
A banda toca o show inteiro, da primeira nota até abrir um pull request, sem nunca colocar as chaves de verdade no bolso. O detalhe é a casa que devolve o som para o palco: coloque uma dessas na lista e a chave de verdade vaza junto com o eco.
O pitch é simples: seu agente de código funciona, e suas chaves nunca entram no sandbox dele. O Claude Code abriu um pull request real em onze segundos segurando apenas placeholders, e revogar o acesso passou a valer já no request seguinte.
O posicionamento é para times de plataforma que fazem self-host do AX e querem o acesso dos agentes governado de forma central, sem confiar em cada sandbox. A nota de rodapé honesta: hoje você configura tudo pelo lado do Substrate, porque o AX ainda não faz isso por você.
Visão Geral
Colocar um agente de código em sandbox protege a sua máquina do agente. Não protege as suas credenciais. Se o Claude Code precisa da API da Anthropic e do GitHub, o setup usual coloca ANTHROPIC_API_KEY e GITHUB_TOKEN no ambiente do sandbox, e um único printenv vindo de prompt injection basta para perdê-las.
O Agent Substrate, o runtime por baixo do Google AX, já tem a alternativa embutida. Tudo o que um sandbox envia, exceto DNS, passa por um egress gateway. Para os hosts listados em uma regra https, o gateway termina o TLS do sandbox, e um efeito replaceHeaders troca um header placeholder por uma credencial real que ele busca em um credential provider, aqui o Kubernetes Secrets. O sandbox guarda um placeholder; o valor real só existe no Secret e no tráfego até a API upstream.
Esta é a parte 3 de 4 de uma série sobre rodar o AX por conta própria no Amazon EKS. A parte 1 cobriu a instalação, a parte 2 o ciclo de vida das tasks, e a parte 4 faz um teste de estresse no mesmo cluster. Aqui eu rodei o Claude Code em uma task do AX com três credenciais injetadas (a chave da Anthropic, um token do GitHub para a API REST e o mesmo token no formato Basic auth para o git) e todos os outros hosts bloqueados. Depois testei a mecânica, fiz o Claude Code abrir um pull request real, tentei extrair as credenciais e fiz a rotação delas com uma task em execução. Tudo rodou no mesmo cluster: AX a496fc1, Substrate d3f2363 (v0.6.0), Claude Code 2.1.314 e gh 2.103.3.
A ressalva principal primeiro: o AX ainda não configura nada disso. O recurso Model dele lê uma chave de um Secret para uso do próprio AX, e uma proposta para injetar credenciais em tasks está aberta como #475. Tudo abaixo é configurado do lado do Substrate, em volta do AX.
Arquitetura
Quatro peças precisam se encaixar, e o AX não fornece nenhuma delas hoje:
| Peça | O que faz | Quem configura |
|---|---|---|
| Credential provider | Lê Secrets do Kubernetes para o gateway; allow list de atespace para namespace, negado por padrão | Instalação do Substrate (--credential-provider='{"name":"k8s.io"}') mais uma edição de ConfigMap |
| EgressPolicy por task | Lista os hosts permitidos e os headers a substituir | kubectl ate create egress-policy, porque o AX não cria nenhuma policy (#449) |
| Confiança na CA do gateway | A task precisa aceitar o certificado do gateway para os hosts que ele intercepta | Um wrapper de boot na imagem da task, porque o AX não adiciona o volume de trust bundle do Substrate |
| Placeholders | Claude Code, gh e git precisam enviar algum valor para o gateway substituir | spec.env e um extraHeader do git |
Setup
Passo 1: A imagem da task
As tasks do AX rodam a imagem de runner do próprio AX, a menos que spec.image diga o contrário. A minha estende essa imagem com Claude Code e gh, e um pequeno wrapper:
# BASE: the ax-task-runner digest from the install post
ARG BASE
FROM ${BASE}
ARG CLAUDE_CODE_VERSION=2.1.314
ARG GH_VERSION=2.103.3
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm jq \
&& npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
&& curl -fsSL -o /tmp/gh.deb "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.deb" \
&& dpkg -i /tmp/gh.deb && rm /tmp/gh.deb
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
COPY egress-ca.sh /usr/local/bin/egress-ca
# (apt cache cleanup and chmod omitted)
Nenhuma credencial vai para a imagem. O wrapper existe por causa do problema da CA no Passo 3.
Passo 2: Secrets, e quem pode lê-los
O 01_setup.py é o único script que lê os valores reais (de um .env local), e ele os envia para a API do Kubernetes, nunca para dentro de uma task:
basic = base64.b64encode(f"x-access-token:{env['GITHUB_TOKEN']}".encode()).decode()
secrets = [
{"kind": "Secret", "metadata": {"name": "anthropic", "namespace": "ax-creds"},
"stringData": {"api-key": env["ANTHROPIC_API_KEY"]}},
{"kind": "Secret", "metadata": {"name": "github", "namespace": "ax-creds"},
# gh and the REST API take a bearer token; git over HTTPS only speaks Basic,
# and the gateway can't base64-encode, so the encoded pair is its own key.
"stringData": {"token": env["GITHUB_TOKEN"], "basic": basic}},
{"kind": "Secret", "metadata": {"name": "canary", "namespace": "ax-creds"},
"stringData": {"value": "canary-1111-not-a-real-credential"}},
]
O provider recusa tudo por padrão. Um ConfigMap concede a um atespace os namespaces que ele pode resolver, e o provider só o lê na inicialização:
# ate-system/k8s-credential-provider-namespace-policy
policies:
- atespace: creds
allowedNamespaces:
- ax-creds
== namespace policy: atespace 'creds' may resolve Secrets in 'ax-creds'
configmap/k8s-credential-provider-namespace-policy unchanged
deployment.apps/k8s-credential-provider restarted
deployment "k8s-credential-provider" successfully rolled out
O token do GitHub é um fine-grained token com escopo em um único repo privado descartável, com Contents e Pull requests em leitura e escrita. Esse escopo importa mais aqui do que em qualquer outro ponto do post; veja O que a injeção não resolve.
Passo 3: Fazendo a task confiar no gateway
Para trocar um header dentro do HTTPS, o gateway precisa terminar o TLS da task, apresentando um certificado que ele emite a partir da própria CA. O código no sandbox precisa confiar nessa CA. O Substrate consegue projetá-la em um actor por meio de um volume de trust bundle systemInfo no template do actor, mas o AX monta os templates das suas tasks e não adiciona esse volume. Então a task recebe a CA de outro jeito: o 01_setup.py lê o certificado público do seu ClusterTrustBundle, toda task o carrega em spec.env, e o wrapper da imagem o adiciona ao store do sistema antes de o comando iniciar:
#!/bin/sh
# egress-ca: runs on every boot, because the root filesystem is rebuilt on every resume
set -eu
if [ -n "${EGRESS_CA_PEM:-}" ]; then
printf '%s\n' "$EGRESS_CA_PEM" > /usr/local/share/ca-certificates/ate-egress-mitm.crt
update-ca-certificates >/dev/null 2>&1
fi
exec "$@"
O certificado é público, então spec.env serve para ele. curl, git e Python usam o store do sistema; o Node o usa via NODE_EXTRA_CA_CERTS, que a imagem aponta para o mesmo arquivo.
Passo 4: Uma egress policy por task
O AX dá ao actor do Substrate de cada task o nome da própria task, então a CLI do próprio Substrate, kubectl-ate, consegue anexar uma policy a ele. O helper que os scripts usam:
def inject(host, header, secret, key, prefix=""):
"""An https rule for one host that swaps one header for a Kubernetes Secret value."""
entry = {"header": header, "credentialUri": f"ate-secret://k8s.io/default/ax-creds/{secret}/{key}"}
if prefix:
entry["prefix"] = prefix
return {"https": {"hostnames": [host], "effects": {"replaceHeaders": [entry]}}}
def set_policy(task, rules):
kate("delete", "egress-policy", task, "-a", "creds", check=False)
kate("create", "egress-policy", task, "-a", "creds", "-f", "-", input=json.dumps({"rules": rules}))
time.sleep(6) # the DNS allow-list follows a policy change within about 5 s
Uma task sem policy não tem rede nenhuma: o DNS dela responde NXDOMAIN para tudo, e é também por isso que uma task do AX recém-criada não consegue fazer git clone. O DNS acompanha uma mudança de policy em cerca de 5 segundos; o gateway faz cache da policy de cada task por até 10.
Testes
Teste 1: Como a injeção se comporta (sem credenciais reais)
O 02_mechanics.py usa o secret canário descartável e o httpbin.org/headers, que ecoa de volta o request que recebeu:
create_task("cred-mech")
show("cred-mech", "getent hosts httpbin.org || echo 'DNS: NXDOMAIN'")
set_policy("cred-mech", [inject("httpbin.org", "X-Canary", "canary", "value")])
show("cred-mech", "curl -H 'X-Canary: placeholder' https://httpbin.org/headers")
== a task with no egress policy: no network at all
DNS: NXDOMAIN
curl exit 6
== policy: https to httpbin.org, swap X-Canary for the canary secret
$ openssl s_client -connect httpbin.org:443 ... | openssl x509 -noout -issuer
issuer=CN=BHBIPH7LG73P7I4NHSYRI64N7S
$ curl -H 'X-Canary: placeholder' https://httpbin.org/headers
"X-Canary": "canary-1111-not-a-real-credential"
-> HTTP 200
== the header is only filled in when the request carries it
"Host": "httpbin.org", (no X-Canary)
-> HTTP 200
== a host the policy doesn't list, and plain HTTP to a listed one
https://example.com/ -> HTTP 000, curl exit 6 (no DNS)
http://httpbin.org/get -> HTTP 403
== a task that doesn't trust the gateway's CA
-> HTTP 000, curl exit 60 (certificate problem)
| Situação | O que o sandbox viu |
|---|---|
| Sem policy | Nenhum DNS para nada |
| Host listado, header presente | O valor do secret chegou ao upstream; o certificado era o do gateway |
| Host listado, header ausente | Request encaminhado sem alteração, nada injetado |
| Host não listado | Sem DNS |
| HTTP simples para um host listado só para HTTPS | 403 do gateway |
| Task sem a CA do gateway | Falha na verificação TLS |
O primeiro resultado também é o alerta. O httpbin ecoa os headers do request, então o “secret” voltou direto para dentro do sandbox. O gateway não consegue distinguir uma API confiável de um eco; só a policy faz isso.
Teste 2: Claude Code com uma chave placeholder
ENV = {"ANTHROPIC_API_KEY": "placeholder-not-a-secret",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"}
ASK = "timeout 90 claude -p 'Reply with exactly: injected-ok' --model claude-haiku-5-5 < /dev/null"
set_policy("cred-claude", [https("api.anthropic.com")]) # allow, no injection
set_policy("cred-claude", [inject("api.anthropic.com", "x-api-key", "anthropic", "api-key")])
== the sandbox's view of the key
ANTHROPIC_API_KEY=placeholder-not-a-secret
2.1.314 (Claude Code)
== allow api.anthropic.com, but inject nothing
Invalid API key · Fix external API key
== now swap x-api-key for the real key
injected-ok
real 0m1.621s
== streaming works too
1
2
3
4
5
Mesmo sandbox, mesmo placeholder; a única mudança é o efeito replaceHeaders. O streaming (--output-format stream-json) passou pelo gateway intacto. Duas notas práticas do caminho até aqui. O claude -p via ax ssh travava até eu redirecionar a entrada padrão (< /dev/null); com isso, toda chamada retornou normalmente. E em um cluster de dois nodes, a injeção só funcionava em um node até eu liberar o tráfego entre nodes no security group, como mostrado no post de instalação.
Teste 3: git e gh sem token
O gh envia Authorization: token <GH_TOKEN> para api.github.com. O git não envia nada até ser desafiado, então a task configura um header para ele enviar de antemão:
git config --global http.https://github.com/.extraHeader 'Authorization: Basic placeholder'
O gateway substitui o valor inteiro do header, prefixo incluído, então token placeholder sai como Bearer <token> e Basic placeholder como Basic <base64(x-access-token:token)>:
GITHUB_RULES = [
inject("api.github.com", "Authorization", "github", "token", prefix="Bearer "),
inject("github.com", "Authorization", "github", "basic", prefix="Basic "),
]
== the sandbox's view: placeholders only
GH_TOKEN=placeholder-not-a-secret
http.https://github.com/.extraheader Authorization: Basic placeholder
== without a policy: no DNS for github.com
check your internet connection or https://githubstatus.com
== gh against the REST API
fabiodouek/my2centsai-sandbox-test private=true
== git clone, commit, push a branch
8373223 Initial commit
remote: https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/ax-demo-push
pushed
== delete the branch again
deleted
Um repo privado clonado e com push feito, a partir de um sandbox sem nenhum token dentro.
Teste 4: O Claude Code abre um pull request
O 05_agent_task.py dá a uma task as três regras e entrega o trabalho inteiro ao Claude Code:
PROMPT = (f"In this git repo, create a new branch named {BRANCH}. Add a file NOTES.md with a short "
"paragraph explaining that this pull request was opened by Claude Code running in a Google AX "
"sandbox on Amazon EKS, and that the sandbox never held the Anthropic key or the GitHub token. "
"Commit it, push the branch, and open a pull request with gh. Reply with only the PR URL.")
sh(NAME, f"cd /workspace/repo && claude -p {PROMPT!r} --model claude-sonnet-5-5 "
"--dangerously-skip-permissions --max-turns 20 < /dev/null")
O IS_SANDBOX=1 no ambiente da task permite que o Claude Code pule os prompts de permissão rodando como root, que é exatamente para o que serve um sandbox.
== Claude Code does the rest
https://github.com/fabiodouek/my2centsai-sandbox-test/pull/13
(11 s)
== the pull request, seen from GitHub
#13 Add NOTES.md
https://github.com/fabiodouek/my2centsai-sandbox-test/pull/13
author: fabiodouek
Onze segundos do prompt ao pull request, com o Claude Code chamando a API da Anthropic, depois o git, depois a API do GitHub, tudo através do gateway.
Teste 5: Tentando extrair as credenciais
O 06_leak_attempts.py guarda os valores reais do .env apenas para procurá-los em cada pedaço de output:
def check(label, output):
found = [n for n, v in SECRETS.items() if v in output]
print(f" {'LEAKED ' + ', '.join(found) if found else 'no real credential'} <- {label}")
check("environment of every process", sh(NAME, "cat /proc/*/environ | tr '\\0' '\\n'"))
check("every file under /", sh(NAME, "grep -rIl -e sk-ant- -e github_pat_ / --exclude-dir=proc ..."))
== inside the sandbox, as root
no real credential <- environment of every process
no real credential <- every file under /, grep for sk-ant- and github_pat_
no real credential <- ax-task-runner's view of the task (metadata server)
== on the wire, from the sandbox's side
> x-api-key: placeholder-not-a-secret
< HTTP/2 200
no real credential <- curl -v request headers
== exfiltration and reflection
000 exit 6
000 exit 6
api.github.com/user -> {"login":"fabiodouek","id":29859411}
no real credential <- GitHub's response to an injected request
== ask Claude Code for its key
The `ANTHROPIC_API_KEY` environment variable is set to the placeholder string
`placeholder-not-a-secret`. It is not a real key.
no real credential <- Claude Code's answer
== from the outside: the task spec and the snapshot in S3
no real credential <- ax get task
no real credential <- the actor's snapshot in S3
As duas linhas 000 exit 6 são um endpoint de exfiltração qualquer (webhook.site) e o serviço de eco do Teste 1, que não está na policy desta task: nenhum dos dois resolve.
| Onde eu procurei | Resultado |
|---|---|
| Ambiente de todos os processos, como root | Apenas placeholders |
| Todos os arquivos do root filesystem | Nenhum sk-ant- ou github_pat_ em lugar nenhum |
| Metadata server do AX dentro da task | Apenas placeholders |
curl -v, a partir do sandbox | O request sai com o placeholder; o 200 prova que ele foi trocado depois |
| Um host de exfiltração qualquer | Sem DNS |
| Perguntando ao Claude Code | Ele informou o placeholder |
ax get task | Apenas placeholders |
| O snapshot da task no S3, após um suspend | Nenhuma credencial, mas é uma verificação fraca: o AX faz snapshot apenas de /workspace, que estava quase vazio |
Teste 6: Rotação e revogação com uma task em execução
O Kubernetes Secret é a única cópia, então alterá-lo deveria alterar o próximo request. O 07_rotation.py aplica patch no Secret e faz polling da API do GitHub de dentro de uma task em execução:
put_token("github_pat_revoked") # kubectl patch secret github --type merge -p {"stringData": {...}}
poll("401")
put_token(TOKEN)
poll("200")
kubectl("-n", "ax-creds", "delete", "secret", "github")
poll("403")
== baseline
HTTP 200
== revoke: replace the token in the Secret with a dead one
HTTP 401 after 3.2 s
== restore the real token
HTTP 200 after 3.1 s
== delete the Secret entirely
HTTP 403 after 3.0 s
egress denied
== put it back
HTTP 200 after 3.1 s
Toda mudança apareceu já na primeira verificação depois dela. Os cerca de 3 segundos são o round trip da própria verificação (ax ssh mais curl): o provider lê o Secret da API do Kubernetes a cada request, sem cache. Um Secret deletado falha de forma fechada com um 403 do gateway (egress denied), e não com um request não autenticado para o upstream.
Resumo dos resultados
| Teste | Resultado |
|---|---|
| Mecânica | Header trocado apenas para hosts listados e apenas quando presente; todo o resto fica sem DNS |
| Claude Code | Invalid API key sem injeção, injected-ok em 1,6 s com ela, streaming funciona |
| git e gh | Repo privado lido, clonado, com push feito e branch deletada, sem token na task |
| PR de ponta a ponta | Aberto pelo Claude Code em 11 s |
| Tentativas de vazamento | Nenhuma credencial real no env, nos arquivos, no metadata, no ax get, no snapshot ou na resposta do agente |
| Rotação | Revogar, restaurar e deletar valeram já no request seguinte; deletar falha de forma fechada (403) |
O que a injeção não resolve
- O agente ainda pode usar as credenciais. Qualquer coisa que ele consiga enviar para
api.anthropic.comouapi.github.comsai com o valor real anexado. O que limita o estrago é o token fine-grained de um único repo, não o gateway. - Um host permitido que ecoa headers devolve o secret. O Teste 1 mostrou isso com o httpbin. Liste apenas APIs nas quais você confia que não vão refletir credenciais.
- A policy é por task, e o AX não a cria. Toda task precisa de um
kubectl ate create egress-policyapós a criação, feito por quem tem acesso ao Substrate. Neste setup, essa é a identidade compartilhadaate-client, uma owner do Substrate, o que serve para um cluster de avaliação e para nada mais. - O wrapper da CA é um workaround. Ele coloca a CA do gateway no ambiente da task porque o AX não projeta o trust bundle do Substrate. Se a CA for rotacionada, as tasks existentes mantêm a antiga até serem recriadas: os specs de task do AX são imutáveis, então todo boot reinstala a CA com a qual a task foi criada.
Limpeza
O 08_cleanup.py deleta as tasks (as egress policies vão junto), fecha os pull requests que abriu, deleta as próprias branches, remove o namespace ax-creds e reseta a concessão de namespace do provider:
for pr in github("GET", f"/repos/{REPO}/pulls?state=open&per_page=100"):
if pr["head"]["ref"].startswith("ax-agent-"):
github("PATCH", f"/repos/{REPO}/pulls/{pr['number']}", {"state": "closed"})
kubectl("delete", "namespace", "ax-creds", "--ignore-not-found")
O cluster em si é desmontado com o 99_teardown.sh da parte 1.
Veredito
O mecanismo é a melhor parte do Agent Substrate que eu testei. As credenciais vivem em um só lugar, o gateway as adiciona apenas para hosts nomeados, todo o resto fica sem DNS, uma revogação vale já no request seguinte, e toda tentativa de vazamento que eu fiz, como root e através do próprio agente, voltou com placeholders. Claude Code, git e gh não precisaram de nada exótico: um valor placeholder e, no caso do git, um header.
A parte fraca é que nada disso vem do AX. Hoje você:
- concede ao atespace acesso ao namespace dos Secrets e reinicia o provider;
- anexa uma egress policy a cada task com a CLI do próprio Substrate, depois que o AX a cria;
- contrabandeia a CA do gateway para dentro de cada task, porque os templates do AX não a projetam;
- e roda tudo isso com uma identidade owner do Substrate.
A proposta aberta do AX (#475) e as egress policies ausentes (#449) são exatamente essas lacunas. Até elas serem resolvidas, isso funciona, mas é encanamento de time de plataforma em volta do AX, e não uma feature do AX.
Se você já roda o AX, configure isso desde o primeiro dia: uma task sem policy não alcança nada de qualquer forma, então a escolha é entre um placeholder e uma chave em texto puro no spec.env, que o ax get task mostra para qualquer um que consiga ler a task.