A compromised sandbox: the threat model¶
What one fully compromised sandbox should not get, what stops it, which test proves it, and what is not covered. Part of the security model.
The attacker controls everything inside one sandbox: the agent's process, its files, ~/.hangar/env (so every command hangar runs there), its git binary and its network traffic. What it should not get:
| Goal | What stops it | Tests |
|---|---|---|
| The upstream GitHub token | Never written into a gateway-mode sandbox (env, files, harness configs). Gateway errors carry exception types, never values; upstream redirects aren't followed or relayed; the host segment must equal the grant's host and a configured upstream, so it can't be pointed elsewhere. | test_gateway_git.py |
| A push outside its grant | The command list is parsed before any pack byte goes upstream: one deadline for the whole list, at most 8 commands, no NUL tricks, no special pkt-lines, strict ref names, no deletes, no tags, no signed pushes, and no push options in any spelling (git enables them for push-options=<anything> too). |
test_gateway.py, test_sandbox_security.py |
Pushing a branch itself (a job's agent, the Cloud Doctor's root-cause job reading untrusted evidence), so on: push workflows run on it |
Every job's sandbox holds a fetch-only grant, whatever its environment's credential_mode: a push is refused at its first request. hangar pushes the commit it checked, after verify, the secret scan and the landing checks, with a grant pinned to that ref and commit for minutes; a lifted copy can push nothing else. |
test_least_privilege.py, test_interactions.py |
The environment's credentials, secrets: or cloud tokens, from repo code in a build (install, the validation's agent-written one) |
Build and validation sandboxes get a fetch-only grant and nothing else: no credential (without the gateway not even git's), no secrets:, no cloud token. A build's mapped build secrets reach its install command alone. |
test_least_privilege.py, test_autoinstall.py |
| Another job's or session's branch | Exact grants for a session's own branch. The hangar/** creation rule can't touch a branch another live sandbox, any session or any unfinished job (queued ones included) owns, can't create one that blocks it (a/b vs a/b/c), and can't create anything under hangar/session/. A new session starts from its branch if it exists, so a planted branch would otherwise become its starting point. |
test_sandbox_security.py |
| A dead or foreign grant | Grants are 256-bit, stored hashed, compared in constant time, and stop working when their sandbox stops, pauses, expires or is destroyed. A fork gets its own. 30 invalid grants a minute from one address get 429 without a lookup. | test_gateway_git.py, test_sandbox_security.py |
| The control plane's attention | 8 requests in flight per grant (counted on the same Authorization header the gateway authenticates), deadlines on reading push and fetch requests, push size cap, a single pass over the command list. What hangar reads back is bounded where it is read, not by sizes the sandbox reports (its ~/.hangar/env can redefine stat and find): command output keeps a bounded tail as it streams (Docker, Kubernetes), and every file hangar reads from a sandbox is read with a cap (Docker, Kubernetes, E2B): results, questions, artifacts, checkpoint parts and run logs; rotated auth files written back on release and login captures (1 MB; a bigger one is not stored); ~/.hangar/env (1 MB; a bigger one is rewritten); harness configs hangar merges into (8 MB; a bigger one is left alone, with a warning); workspace snapshots (HANGAR_SNAPSHOT_MAX_MB, 512); and the MCP read_file tool (its first 10 MB). |
test_sandbox_security.py, test_e2e_sandbox_limits_docker.py, test_sweep_caps.py |
The requester's machine (session pull --checkout) |
The untracked-files archive is the sandbox's: only regular files, directories and in-tree symlinks are restored, never under .git (any spelling), never through a symlink (including one the session's patch created), never over an existing file, at most 20,000 entries and 1 GB (the archive is capped compressed; gzip expands ~1000x). The checkpoint's head must be a commit id. The patch goes through git apply, which refuses .git paths. |
test_sandbox_security.py |
| The dashboard's origin | Artifacts are served as attachments with Content-Security-Policy: sandbox and nosniff; active types are downgraded to text/plain. |
test_exchange.py |
Sending code or tokens to a host of its choosing (allowlist/none environments) |
The network admits only the git gateway and the egress proxy, with no DNS. The proxy authenticates the sandbox and checks each connection's host against the sandbox's allowlist, then reads the whole ClientHello: one SNI, the CONNECT host, and no Encrypted ClientHello (GREASE included), no SSL 2/3 hello, no repeated extension, and the same for a second hello after a HelloRetryRequest. Plain HTTP must carry the host in Host. Approvals come from the owner, never from the agent. Every denial is audited and shown in the session, and allowed traffic is audited per host. |
test_egress.py, test_egress_tls.py, test_k8s_network.py |
Publishing to a package registry or GitHub with its own token (allowlist) |
With the gateway configured, pypi, npm and go are served by hangar's read-only mirror (GET and HEAD, under the upstream only, no client credentials), so the registries' hosts, which take uploads, stay closed; crates opens the read-only index and download hosts only; packages no longer includes github, and gateway-mode sandboxes read their repo's API through the gateway, GET only. Go modules come only from the module proxy's cache, so a module path can't make it fetch from an origin. |
test_pkgmirror.py, test_e2e_egress_docker.py |
| Sending an experiment's hidden tests out, or forging their result (a trial's patch, run by its grader) | The grader's sandbox is sealed: egress none with no host at all, on the proxy-only network, no credential, mcp:, computer or services, its git grant revoked before the patch's code runs. The hidden tests are outside the repo (root-owned on Docker), and hangar-grade pytest loads nothing the patch added first. With the uid split hangar's user writes the grade; base-must-fail checks and canaries flag what isolation can't stop. See graders. |
test_sealed_grader.py, test_grade_forgery.py, test_e2e_sealed_docker.py |
| The VPC, the cluster or the metadata server through the proxy | The proxy resolves names itself and connects only to the public address it checked (a name that re-resolves later doesn't matter). Loopback and link-local addresses are refused even for HANGAR_EGRESS_PRIVATE_HOSTS. |
test_egress.py |
| Getting a secret into a pushed branch, a PR or a Slack thread (by accident, the common case) | Before hangar delivers a job, the change and its commit messages are scanned for stored values, their encodings, and high-signal token formats. A hit blocks the push and goes back to the agent with file and line, never the value. Every push through the git gateway, the agent's own included, is scanned again there from the pack itself, before any of it goes upstream. PR text, comments and artifacts are masked. | test_secretscan.py, test_pushscan.py, test_gateway_git.py |
Reading a stored secret back through hangar (exec output, events, results) |
Every stored value is masked verbatim and, from 12 characters, reversed, in hex, base64 at any alignment (wrapped too) and percent-encoded. The B4 repro (\| rev) now comes back ***. This is not a wall: other encodings get through (redaction). |
test_secretscan.py |
| A build secret in the running sandbox | Build secrets are named only in admin-set places, reach only the build step (BuildKit secret mounts, or one exec's environment in a build sandbox checked for leftovers before capture), and are never written to ~/.hangar/env. |
test_secretscan.py |
A cloud key it can keep (cloud: environments) |
No cloud key is stored or injected: a per-sandbox OIDC token, scoped to one audience, valid at most lifetime_minutes, renewed only while the sandbox runs, is exchanged by the cloud for short-lived credentials. |
test_cloudcreds.py |
| Cloud credentials | GKE node pools use GKE_METADATA (Workload Identity), so pods never see the node's service account; sandbox pods mount no Kubernetes token. sandbox-network.yaml blocks the metadata server, which matters with Pod snapshots: their KSA can read every sandbox's snapshot. |
manifests checked with a server-side dry run |
| Other sandboxes and the API | GKE: no ingress to sandbox pods (except from hangar's pods to the envd port), no egress to private ranges; only the gateway port of hangar pods is open. Docker: no traffic between sandboxes on hangar's network. | manifests checked with a server-side dry run and test_k8s_network.py (every policy file read together); test_e2e_isolation_docker.py |
| A turn's driver and the approvals it relays | Docker sandboxes whose agent has no sudo run each turn split (the uid split): the driver, its key, its control files and the turn's script belong to a hangar user, the agent's harness runs as the sandbox user with no capabilities, so code a person allowed can't read the key, write a command or signal or trace the driver. The harness and what it starts run with no_new_privs; hangar reads a split run's status, log and approvals from its own files, never through the agent's links. In ask and deny-risky turns hangar also owns the harness's binary, environment and settings (hangar-owned harness settings). Elsewhere one user, with a warning on ask and deny-risky turns (HANGAR_UID_SPLIT=required refuses them). |
test_e2e_isolation_docker.py, test_e2e_harden_docker.py, test_isolation.py, test_harden.py, test_harden_real_clis.py |
| The node, other pods or the metadata server from a heavy sandbox (root in its VM, with Docker) | A heavy sandbox runs only on a provider with a VM around it (Kata, E2B, CubeSandbox; Docker only with HANGAR_HEAVY_DOCKER_PRIVILEGED, for CI). Under Kata the container is privileged inside the guest only: kata-deploy sets containerd's privileged_without_host_devices, so no host device reaches the VM. The containers dockerd starts are NATed to the pod's address, so the pod's NetworkPolicy applies to them (no private ranges, no link-local, only the proxy in an allowlist environment); dockerd listens on its Unix socket only. Docker Hub pulls go through the egress policy or hangar's read-only mirror, which takes the sandbox's credential in its path and serves only running heavy sandboxes. Setting heavy and big profiles on an environment is an admin's, and Docker is deny by default for a repo: its own .hangar/environment.yaml only requests it, honored where an admin's repo policy says heavy: allow (anyone who can push the default branch writes that file). At admission (heavy-policy.yaml), a privileged container or an added capability in hangar-sandboxes needs a Kata runtime class, a RuntimeClass named kata* needs a Kata handler, and no sandbox pod gets host namespaces, hostPath (Kata would share it into the VM) or hostPort. The heavy nodes run as a node service account of their own with only a node's roles, so a guest escape to the node doesn't get the project's default compute identity. |
test_heavy.py, test_heavy_mirror.py, test_e2e_heavy_docker.py; the policies checked against a real kube-apiserver 1.36 (review of #117); the heavy acceptance check probes the metadata servers from a container inside the VM |
| Pushing, or reaching a host of its choosing, from an intake job (a prompt injection in an issue, ticket or Slack thread steering the agent) | Intake posture enforce (the default): an intake job is refused at submit without the git gateway, so its sandbox only fetches; its sandbox gets at least allowlist egress and never claims an open environment's warm sandbox. No setting turns it off: warn and off were removed after v0.2.0 (KAN-74). |
test_posture.py, test_least_privilege.py |
| Root in its sandbox, to rewrite what hangar reads there or the harness's managed settings | sudo is off by default, so default environments get the uid split on Docker; under enforce a job's Docker sandbox runs with no-new-privileges, so sudo fails even where the image has it, and an intake job on a heavy environment needs the repo policy's allow_heavy_for_intake (#124). |
test_sudo.py, test_e2e_isolation_docker.py |
| Getting its own job approved (a leaked client config, a service token, the requester approving their own held work) | One rule on every path (MCP approve_job, REST and hangar job approve, GitHub and Jira /hangar approve): an admin or a linked repo approver, over MCP and REST only with a login token (never the mcp token hangar connect writes, or a jobs token), and never the job's own person or requester (HANGAR_SELF_APPROVAL=allow_solo only while one person exists). Refusals are audited (#121). |
test_approvals.py |
| A tool or route that answers more on one surface than the other | Every MCP tool, resource and REST route is listed with its scope and owner rule, read from what the code enforces, and privileged verbs are called on both surfaces as a member, an admin's mcp token and an admin login (#122). |
test_scope_parity.py |
| Landing, pushing or opening egress after reading a prompt injection mid-turn (a web page, an untrusted MCP server) | The turn is tainted for good; its PR opens as a draft labelled hangar:tainted, a second person must approve it to land, egress requests need an admin or a repo approver, and under ask/deny-risky pushes, PRs, network commands and MCP write tools are risky. The agent can't clear the taint (#129, #130). |
test_taint.py, test_taint_gates.py |
| Starting jobs from Jira with a lifted Automation token | An Automation request starts a job only within 10 minutes of the issue's trigger transition, at most 3 a day per issue; a token-authenticated request posing as a webhook event is ignored; stale, wrong-event and bad-token requests alert the admins (#128). | test_intake.py |
| A person's subscription login, used by automated work against the vendor's terms | Jobs, schedules, webhooks, experiments and replays resolve credentials as automated: oauth_token and auth_file are dropped, or refused when named, before any sandbox starts; trials and graders never get one. No setting relaxes it: HANGAR_SUBSCRIPTION_LOGINS=any was removed after v0.2.0 (KAN-74). |
test_subscription_logins.py, test_teamcreds.py |
| Another sandbox's envd daemon | A daemon accepts only requests signed with its own sandbox's token: HMAC(vault key, sandbox id), never stored and never sent. Each request carries an HMAC over its time, a nonce, method, path and body hash, usable once and for five minutes, so a request that reaches the wrong address (a reused IP) gives whoever got it nothing; a peer without the token is refused before its body is read and can't spend nonces, and connections are capped per address and closed when idle. Answers must carry an HMAC proof of the token, so nothing else at a sandbox's address is taken for its daemon. What a daemon returns is capped (message size, output tail, read limit), never decompressed, and waited for only so long. | test_envd.py, cargo test in envd/ |