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

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 overrides. Resolved at submission and carried in the eval-set payload; the runner pod's environment is not consultedSame

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 (the latest is 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:

  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

    Exits 0. Catches typos, indent bugs, dangling placeholders. Chart path: 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

    .eval file lands locally with status success. 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.