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 Control Tower. Control Tower substitutes the placeholders listed under Substitution rules and passes everything else verbatim to the Hawk Kubernetes (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. 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). A main checkout between releases runs against the last released simulator server, so a released server must keep accepting newer runners' registration payloads, and a server change that requires a new registration field lands only after a release whose runner already sends it.

Substitution rules

PlaceholderResolved fromWhen
${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 overridesSame
env-internet-simulator:latestThe submitting Control Tower's release tag v<version> (see Image tags); HAWK_IS_TAG in the submitter's .env overridesSame

The simulator tag is resolved at submission and carried in the eval-set payload; the runner pod's environment is not consulted. Every other ${...} token is passed through verbatim. Control Tower writes the substituted file to a sibling, hawk-values.rewritten.yaml, and points the sandbox spec at it; the original stays on disk.

Image reference convention

  • Control Tower-built environment images: ${CT_IMAGE_REGISTRY_PREFIX}/env-<envname>:${HAWK_ENV_TAG}
  • Internet-simulator: ${CT_IMAGE_REGISTRY_PREFIX}/env-internet-simulator:latest (the latest is a placeholder Control Tower pins, see Image tags)
  • Third-party images (postgres, redis, nginx, etc.): literal pull strings with no substitution, which must be anonymously pullable from public Docker Hub or present 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.

Do not declare additionalResources with fixed names. Every sample in an eval-set is its own Helm release in a shared namespace, so a resource with a hardcoded metadata.name, such as a ConfigMap, collides across concurrent releases and the second sample fails with a Helm ownership conflict (invalid ownership metadata; ... release-name must equal). The chart runs additionalResources through tpl, so {{ .Release.Name }}-scoped names render, but services.*.volumes entries are not templated, so a release-scoped ConfigMap cannot be mounted. Instead, bake file content into the env image or carry it as a base64 env var that the service command writes out (as INIT_SQL_B64 above). The only safe additionalResources are ones whose names Control Tower makes unique 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 Control Tower 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

Each sample gets its own internet-simulator pod.

Validation

Run both checks:

  1. 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.yaml

    It must exit 0; this catches typos, indentation bugs, and dangling placeholders. The chart is bundled with inspect_k8s_sandbox at .venv/lib/python*/site-packages/k8s_sandbox/resources/helm/agent-env/.

  2. 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

    A new env is done when this writes a local .eval file with status success.

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. With --no-internet, Hawk rejects environments that declare upstream API proxies, at submission preflight and again on the runner before simulator injection, because the chart does not set up the agent-only hostname aliases and CA trust those proxies need. Run such environments on Docker.

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. With internet access enabled, the environment's authored chart grants apply.