Skip to content

Sandbox Network Isolation

Hawk runs each sample's agent tool code in a Kubernetes sandbox whose network is deny-by-default: with no configuration, a sandbox pod can reach cluster DNS and its own sibling pods, and nothing else. Internet access is something you opt into per task, not something you opt out of.

This matters for any evaluation that runs untrusted or model-generated code, and it is the required posture for cyber-related evaluations, where the agent must not reach the public internet or the infrastructure around it unless the engagement explicitly scopes it in.

The trust boundary

Hawk keeps the model credentials out of the environment the agent can touch:

  • The runner (the Inspect eval loop) is the trusted process. It makes all model API calls, talking to the middleman proxy with the caller's short-lived OAuth JWT — never a provider API key. Its egress is open (it needs the gateway plus package installs).
  • The sandbox (where the agent executes bash/tools) is the untrusted environment. It has no model API key and no gateway URL, and its egress is deny-by-default. Setting api_key in an eval config is rejected outright.

So a provider key is never injected into a container the agent controls — the agent reaches the model only indirectly, through the trusted runner. This holds for every sandbox provider.

Sandbox providers

The rest of this page describes the Kubernetes sandbox provider — Hawk's default and what production deployments use. It translates a task's Compose or values.yaml definition into Cilium network policies, and it is what enforces the per-eval configuration and always-on guarantees below.

Other providers isolate differently, and the specifics here do not carry over:

  • Docker (hawk local, local Compose runs) applies Docker's own network semantics directly — e.g. network_mode: none removes the container's network interface entirely. The Cilium VPC-deny and cross-sample guarantees below do not apply.
  • EC2, Modal, and other providers enforce whatever that provider and your deployment configure. The network_mode → Cilium mapping and the always-on guarantees here are not automatic; verify isolation against the provider before relying on it.

The provider is selected per evaluation, by the task's Inspect sandbox type.

Specifying network access per eval (Kubernetes provider)

Network policy lives in the task's sandbox definition (the Docker Compose file or Kubernetes values.yaml the task references), not in the eval-set YAML. Hawk reads that definition and translates it into Cilium network policy:

Compose declaration Internet Other services in the same sample
network_mode: none no no (deny-all)
a network marked internal: true no only services sharing that network
nothing specified no (unless a sibling grants it) all of them, but only if the file defines no networks
network_mode: bridge yes all of them, but only if the file defines no networks
one non-internal, bridge-driver network every service joins yes yes

Internet is granted per sandbox, not per service

If any service sets network_mode: bridge (or the file defines a single non-internal, bridge-driver network that every service joins), every service in that sandbox reaches the internet — except any service set to network_mode: none, which stays fully denied. bridge grants * (every domain); for a scoped engagement, use the allowlist below instead.

Because the grant can't be scoped to one service, Hawk rejects a compose that grants internet while some service sits only on an internal: true network — that service asked to stay offline and Hawk cannot honour it here. Use network_mode: none for a service that must stay offline regardless of its siblings.

Reachability between services is a separate question

Internet access and sibling reachability are decided by different rules, so read both columns independently. If the compose defines no networks, every service can reach every other. If it defines networks, a service is reachable only by peers sharing one of its networks — and a service that joins none, including one whose only setting was network_mode: bridge, is unreachable from all of them.

Hawk can only express that split when the file declares networks, so it rejects a compose that sets network_mode: bridge on one service and leaves another on no network at all while declaring none. Under Docker those two sit on separate networks and cannot reach each other; declare a shared network and put every service on it.

Full isolation: network_mode: none

Gives a service no network access — no internet, no cluster DNS, and no network traffic to or from sibling services. Use it for a container that must stay unreachable over the network, e.g. one holding reference solutions or hidden tests. It stays denied even while a sibling has internet:

services:
  default:
    image: my-task-image
  scorer:
    image: my-scorer-image
    network_mode: none   # deny-all, even though `default` may reach the internet

Combining network_mode: none with networks on the same service is rejected — they ask for opposite things.

Isolation is at the network layer

none blocks communication initiated from inside the sandbox. The trusted Inspect harness in the runner still reaches every container out-of-band via the Kubernetes API (exec, file read/write) — that path drives scoring and setup, and is how controlled cross-sandbox channels are built (e.g. a trusted proxy relaying between an isolated container and the agent). The network policy does not touch it.

Siblings without internet: internal: true

To let services talk to each other but not the internet, put them on a network marked internal: true:

services:
  game:
    image: my-registry/game:v1
    networks: [task-net]
  default:
    image: my-registry/agent:v1
    networks: [task-net]
networks:
  task-net:
    driver: bridge
    internal: true

This only holds if no other service opens the sandbox up. Because the internet grant is sandbox-wide, internal: true cannot keep one service offline while a sibling sets network_mode: bridge — Hawk rejects that combination rather than quietly putting the service online. To isolate a single service regardless of its siblings, use network_mode: none.

network_mode: bridge opens everything. For an engagement where only specific targets should be reachable, use a Kubernetes values.yaml sandbox config and name exactly what the agent may reach — everything else stays blocked:

allowDomains:
  - "target.example.com"
allowCIDR:
  - "10.20.0.0/24"
services:
  default:
    image: my-task-image

allowDomains, allowCIDR, and allowEntities are the inspect_k8s_sandbox agent-env chart values. Only none and bridge are accepted for network_mode; any other value fails the run (checked per service).

Always-on guarantees

On the Kubernetes provider these hold regardless of the per-task config above:

  • VPC infrastructure is blocked. A cluster-wide Cilium policy denies every sandbox pod egress to the primary VPC subnet and the hybrid node/pod networks, plus EC2 IMDS (169.254.169.254) and EKS Pod Identity (169.254.170.23). It denies these specific ranges rather than all VPC CIDRs, so cluster DNS keeps working.
  • Cross-sample isolation. One sample's pods cannot reach another's. The sandbox allow rules are keyed to a per-sample Helm release name, and a sandbox's egress permits only same-release endpoints plus world — which does not cover in-cluster pod identities. Note this is enforced on the source side: not every rule in the namespace is release-scoped (the SSH ingress rule below is not), so don't assume any in-cluster workload is fenced off by default.
  • SSH ingress on port 2222. Every sandbox's default service accepts ingress from all entities on TCP 2222, which is how the jumphost reaches a human-eval session. It is reachable from hosts outside the pod network, though not from other sandboxes or runners, whose own egress rules stop them. Nothing listens on 2222 unless the task runs an SSH server.

gVisor is not on by default — opt in per eval set

The sandbox chart's own default is runtimeClassName: gvisor, but Hawk overrides it before the chart sees it: every service that doesn't name a runtime gets CLUSTER_DEFAULT, which the chart renders by omitting the field entirely — so the pod runs under the node's default runtime (runc), with no syscall boundary.

Kubernetes has no "default RuntimeClass" concept (unlike StorageClass) — a pod must name it. Where the RuntimeClass is installed (hawk:enableGvisor), ask for it from the eval-set config, without touching the task's sandbox definition:

isolation:
  runtime_class: gvisor

A task can also pin runtimeClassName on a service in its own values.yaml; the eval-set setting wins where both are present. Don't assume gVisor is in use without checking the rendered values. See Hardening High-Risk Evals for the full posture.

Verify before a sensitive run

Network isolation is enforced structurally — Hawk does not yet run a pre-flight sandbox-escape probe or auto-validate the config before a cyber eval. Before a run where isolation matters, render the task's sandbox values and confirm allowDomains / allowEntities / allowCIDR contain only what you intend, and in particular that world / * appear only when you meant to open the internet.

Human oversight

Network isolation bounds where the agent can go; it does not gate what actions it takes. For a human to review and approve or reject tool calls live — and to terminate a run that exceeds scope — use human-in-the-loop approvals (acp_server / approval on the eval-set config). The two are complementary: isolation is the network boundary, approvals are the action boundary.

See also the Security reference for the deployment-level view.