The hawk-values contract
Every env that runs on hawk (ct run eval --hawk) carries a hand-authored hawk-values.yaml in codebase/, alongside its Dockerfile and compose.yml:
<env-repo>/
codebase/
Dockerfile # shared with local-docker evals
compose.yml # drives local-docker evals
hawk-values.yaml # hand-authored K8sSandboxEnvironmentValues; drives the hawk path
main_tasks/
side_tasks/
This file is the contract between env authors and CT. CT performs exactly two substitutions on it at runner-task-start; everything else is passed verbatim to the hawk K8s sandbox chart (inspect_k8s_sandbox).
Required header
Every hawk-values.yaml starts with:
# hawk-values for <env_id> — see docs/hawk-values-contract.md for the contract. # CT substitutes ${CT_IMAGE_REGISTRY_PREFIX} and ${HAWK_ENV_TAG} at submission time.
Image tags
Each env's images are tagged with that env's commit SHA: the operator publishes at the SHA of the env's .settings/ checkout (see "Env-image registry" in ops/docs/hawk-deployment.md), and ct run eval --hawk resolves ${HAWK_ENV_TAG} to the same SHA, so the two match by construction. Setting HAWK_ENV_TAG in .env pins every env in a run to that one tag. The shared internet-simulator image is published under Control Tower's release tag (v<version>, e.g. v5.0.0); ct run eval --hawk pins each env's env-internet-simulator:latest ref to the submitting Control Tower's own release tag, so the simulator server accepts the registration protocol that runner sends. Setting HAWK_IS_TAG in .env pins it to another tag instead (latest for a simulator built from unreleased main). Between releases a main checkout therefore runs against the last released server, so a released simulator server must keep accepting newer runners' registration payloads until the next release: a server-side change that requires a new registration field lands only after a release whose runner already sends it.
Substitution rules
| Placeholder | Resolved from | When |
|---|---|---|
${CT_IMAGE_REGISTRY_PREFIX} | User's .env (passed via eval-set YAML to runner pod) | Runner-task-start, before K8sSandboxEnvironmentConfig(values=Path(...)) is constructed |
${HAWK_ENV_TAG} | The env's commit SHA (see Image tags); HAWK_ENV_TAG in .env overrides | Same |
env-internet-simulator:latest | The submitting Control Tower's release tag v<version> (see Image tags); HAWK_IS_TAG in the submitter's .env overrides. Resolved at submission and carried in the eval-set payload; the runner pod's environment is not consulted | Same |
Every other ${...} token is passed through verbatim. CT writes the substituted file as a sibling: hawk-values.rewritten.yaml. The sandbox spec points at the rewritten file. The original stays on disk for debugging.
Image reference convention
- CT-built env images:
${CT_IMAGE_REGISTRY_PREFIX}/env-<envname>:${HAWK_ENV_TAG} - Internet-simulator:
${CT_IMAGE_REGISTRY_PREFIX}/env-internet-simulator:latest(thelatestis a placeholder CT pins, see Image tags) - Third-party images (postgres, redis, nginx, etc.): literal pull strings, no substitution. Must be public-DockerHub-pullable (kubelet anonymous pull) OR also in the operator's registry.
Worked example: clinical_trial
Local-docker shape (codebase/compose.yml):
services: default: image: linuxarena/env-clinical_trial:${ENV_IMAGE_TAG_CLINICAL_TRIAL:-latest} build: ... depends_on: postgres: condition: service_healthy volumes: - ./init/db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro postgres: image: postgres:16.12 environment: POSTGRES_DB: vaccine_trial POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres
K8s shape (hawk-values.yaml):
# hawk-values for clinical_trial — see docs/hawk-values-contract.md for the contract. # CT substitutes ${CT_IMAGE_REGISTRY_PREFIX} and ${HAWK_ENV_TAG} at submission time. services: default: image: ${CT_IMAGE_REGISTRY_PREFIX}/env-clinical_trial:${HAWK_ENV_TAG} command: - /bin/bash - -c - /app/restart.sh & exec tail -f /dev/null readinessProbe: exec: command: [sh, -c, "pg_isready -h postgres -U postgres"] initialDelaySeconds: 5 periodSeconds: 2 postgres: image: postgres:16.12 env: - { name: POSTGRES_DB, value: vaccine_trial } - { name: POSTGRES_USER, value: postgres } - { name: POSTGRES_PASSWORD, value: postgres } - { name: INIT_SQL_B64, value: <base64-encoded-init.sql> } command: - /bin/sh - -c - printf %s "$INIT_SQL_B64" | base64 -d > /docker-entrypoint-initdb.d/init.sql && exec docker-entrypoint.sh postgres readinessProbe: exec: command: [sh, -c, "pg_isready -U postgres"]
Both files coexist in codebase/. compose.yml drives local-docker evals; hawk-values.yaml drives the hawk path.
Never declare fixed-name additionalResources. Every sample in an eval-set is its own Helm release in a shared namespace, so a resource with a hardcoded metadata.name (a ConfigMap, say) is fought over by every concurrent release — the second sample errors with a Helm ownership conflict (invalid ownership metadata; ... release-name must equal). The chart runs additionalResources through tpl, so {{ .Release.Name }}-scoped names render fine — but the services.*.volumes entries that would reference such a resource are rendered verbatim (no templating), so a release-scoped ConfigMap cannot be mounted. Ship file content some other way: bake it into the env image, or carry it as a base64 env var written out by the service command (as INIT_SQL_B64 above). The only safe additionalResources are ones whose names CT itself uniquifies per sample (the Docker Hub pull secret).
Internet-simulator pattern
Envs that need the internet simulator declare it as a sibling service in their own values. No CT auto-injection:
services: default: # ... env vars to talk to IS, agent-side startup wrapping ... env: - { name: IS_BASE_URL, value: "http://internet-simulator" } - { name: LB_PERSONA_HOSTS, value: "filebin.net packages-cdn.com" } command: - /bin/bash - -c - | # Wait for IS, write /etc/hosts overrides, register personas, then run env startup until curl -sf "$IS_BASE_URL/health"; do sleep 1; done IS_IP=$(getent hosts internet-simulator | awk '{print $1}') for h in $LB_PERSONA_HOSTS; do echo "$IS_IP $h" >> /etc/hosts; done curl -sfX POST "$IS_BASE_URL/personas" -H 'Content-Type: application/json' -d '...' /app/restart.sh & exec tail -f /dev/null internet-simulator: image: ${CT_IMAGE_REGISTRY_PREFIX}/env-internet-simulator:latest securityContext: capabilities: add: [SYS_ADMIN] readinessProbe: exec: command: [sh, -c, "curl -sf http://localhost/health"] initialDelaySeconds: 2 periodSeconds: 1
This is one IS pod per cell — i.e., one IS pod per sample. No pooling across cells.
Validation
Two layers, both required:
-
Local helm template (every env author runs locally before merging; env-repo CI runs the same):
helm template test-release <chart-path> \ --set CT_IMAGE_REGISTRY_PREFIX=example.invalid \ --set HAWK_ENV_TAG=test \ -f hawk-values.yamlExits 0. Catches typos, indent bugs, dangling placeholders. Chart path: bundled with
inspect_k8s_sandboxat.venv/lib/python*/site-packages/k8s_sandbox/resources/helm/agent-env/. -
End-to-end smoke (after the env's images are published):
ct run eval --hawk -e <env> -t <one-cheap-main-task> \ --untrusted-policy honest --untrusted-policy-arg model=anthropic/claude-sonnet-4-6 \ --protocol untrusted-agent --protocol-arg max_steps=10 \ --sample-time-limit 600.evalfile lands locally with statussuccess. This is the definition of done for a new env.
Network isolation
Hawk runs samples in its native Kubernetes sandbox and rejects EC2, replay, simulated, external-sandbox, sandbox-isolation, and simulator-disabled settings in the submitted task configuration.
ct run eval --hawk --no-internet clears the chart's allowDomains, allowDomainsPorts, allowCIDR, and allowEntities grants. The chart's Cilium policy still allows communication within the sample and DNS for its services, while denying direct external egress from every sample service. The sandbox configuration is carried in the Hawk task payload and recorded with the run.
Simulator personas and exposed services remain reachable within the sample. Restricted Hawk rejects environments declaring upstream API proxies during submission preflight and again before simulator injection on the runner: the current chart does not configure agent-only hostname aliases and CA trust before service startup, so those proxies cannot work transparently. Use Docker for environments requiring upstream proxies until Hawk has that initialization path.
This requires the supported inspect_k8s_sandbox chart and a Cilium cluster enforcing its CiliumNetworkPolicy resources. The chart always creates Cilium policies, so a cluster without that CRD fails installation. Restricted additionalResources accepts only core-v1 Secrets and ConfigMaps, excluding service-account tokens; template substitutions are limited to {{ .Release.Name }} and {{ .Release.Namespace }}. Workloads, policy resources, and executable Helm templates are rejected because they could bypass the restriction. With internet access enabled, the environment's authored chart grants apply.