What you are running
A mission is intentionally broken software. Its services carry real, exploitable flaws, because an agent that cannot compromise anything cannot be measured. Pulling a mission from the library means pulling a container image built to be broken into. XORCISE runs each lab mission inside a fused container — a privileged container hosting its own nested Docker daemon, which in turn runs the mission’s stack. The privilege is what makes nesting possible. It also means the fused container is not a hardened boundary: treat a container escape as possible rather than impossible. Nesting is the only topology. A mission’s containers never land on your own Docker daemon, so parallel runs cannot collide on a fixedcontainer_name or a published port, and nothing a mission declares becomes a container you have to reap by hand. xorcise doctor reports whether this host can nest containers at all under the nested containers check; a host that cannot is refused at run creation rather than partway through a run.
Run XORCISE on a machine you own, on a network you trust, holding data you could afford to lose. Do not run it on a production host, a build server, a jump box, or anything else shared with work you care about.
The trust boundary
Three zones matter. Your machine is inside the boundary. The per-run environment is outside it, and XORCISE keeps it away from your host. The network your machine sits on is also outside it: the default bind keeps it off the operator surface, but nothing on your own machine is kept off it, and widening the bind hands the network the same unauthenticated access every local process already has.What isolation covers, and what it does not
Isolation in XORCISE is three mechanisms, all real and all narrow. Topology. Mission services live on bridge networks belonging to a Docker daemon nested inside the fused container. Your host has no route to those addresses. Your agent reaches them only across the run’s private tailnet, and only by IP address — XORCISE runs its Headscale control plane with MagicDNS off and no global nameservers, so nothing on the tailnet resolves by name. Each run carves its own/24 out of 10.200.0.0/16 and holds that reservation until it goes terminal.
Confinement. Every network a mission declares is created internal, so no mission service has a route off the box. The run’s own Tailscale router sits on the single non-internal network and is the run’s only egress. The tailnet is therefore the only path between your agent and the mission — not a shared Docker bridge — and a compromised target has nowhere to go except back across that tailnet.
Confinement covers every network in the mission’s compose file, not just the entry networks the agent is given addresses on, because a service attached to both would otherwise keep its route out through the second. Two shapes that would make confinement a lie are refused at build time rather than deployed:
- A mission declaring a network named
xorcise-egress, the reserved name for the run’s own egress network. Itsinternalflag would be silently overwritten. - A mission declaring an
external: truenetwork. Compose did not create it, sointernalis a no-op on it.
environment.allow_egress to true in mission.json, which disables confinement for that mission entirely. It sits in the manifest precisely so you can see it before you pull: check it on any mission you did not write.
A fail-closed access policy. XORCISE renders exactly one Headscale accept rule per run: that run’s agent user, to that run’s entry CIDRs, and nothing else. There is no default-allow rule, so Headscale’s default-deny covers everything the rules do not name. Before applying any policy XORCISE refuses it outright if it contains a wildcard source or destination, more than one rule per agent user, or a destination belonging to another run. Agent A can reach mission A and cannot reach mission B.
The reverse direction — the mission opening connections back to the agent — is allowed, on any port, because a mission may legitimately expect the agent to listen (a callback, a shell, a C2 port). What keeps that safe is the source pin: each run’s router carries a per-run ACL tag, so a run’s inbound rule names exactly one router, its own. The policy gate pairs each router tag with its single permitted agent and rejects any policy that does not, so a mission that pivots through its router has no compiled path to a concurrent run’s agent.
What isolation is not:
- It is not a hardened egress firewall. Confinement is Docker’s
internalflag plus a single-egress topology, enforced by the same nested daemon the mission runs on. It stops a mission service from routing out; it is not a boundary you should rely on against an attacker who has escaped the container. - It is not in force for a mission that sets
allow_egress. That mission’s networks are ordinary bridge networks with ordinary NAT, exactly as every mission behaved before this release. - It is not a defence against container escape, for the privilege reason above.
Open ports and no authentication
In the defaultlocal deployment topology, the REST plus web UI plane and the OTLP receiver bind loopback — 127.0.0.1 and the IPv6 ::1 — plus, on native Linux, the Docker bridge gateway. The bridge address is what makes a container-launched agent’s host.docker.internal reachable; on Docker Desktop that name already resolves to the host loopback, so no extra address is needed there.
The bind widens to the IPv4 wildcard 0.0.0.0 in exactly two cases: you set XORCISE_HOST=0.0.0.0 yourself, or you are on Linux and XORCISE cannot determine a bindable Docker bridge gateway at boot, where a loopback-only bind would break every run rather than lock anything down.
The host setting, which defaults to 127.0.0.1, is the address XORCISE prints and health-polls. Setting it to something other than 0.0.0.0 does not narrow the bind below loopback.
Neither plane authenticates.
- The operator API —
/api/runs,/api/agents,/api/config,/api/missions,/api/catalogand/api/system— accepts any caller. /api/fs/listis the one exception, and it is gated on where you connect from rather than on a credential. It surfaces the server host’s own directory tree, so it answers loopback clients only; a LAN peer under a widened bind, or an agent container on the Docker bridge, gets a403.- The per-run run-control verbs under
/api/runs/{run_id}/…do carry a per-run bearer token, but they live in the same application on the same port. That token scopes those verbs only; it protects nothing else. - The OTLP receiver’s
/v1/tracesand/v1/logstake no credential at all. Runs are separated by thexorcise.run_idresource attribute alone.
xorcise up printed rather than assuming 3001 and 4318.
What leaves your machine
XORCISE is local by default, not local unconditionally. Three things reach the network, and the judge is the one that matters most.
Docker also pulls the Headscale, Tailscale and mission-base images the first time it needs them. That is Docker’s egress on your behalf, not XORCISE reporting anything.
Everything else stays on your machine. Raw OTLP traces and logs, runs, agents, results, grades and terrain data all live in
~/.xorcise/xorcise.db; mission bundles you ingest yourself live in ~/.xorcise/missions. XORCISE has no analytics, no crash reporting, no usage beacon and no phone-home. The trace-mirror settings (otel_mirror_enabled, otel_mirror_endpoint) are a reserved seam with no exporter behind them; enabling one fails fast at boot rather than quietly forwarding anything.
Never repeat the shorter claim that nothing leaves your machine. Once a judge model is configured, the transcript of every graded run goes to that provider.
Where secrets live
Your model API keys live in~/.xorcise/.env, created at mode 0600 and re-set to 0600 on every write. That file is the only copy — nothing else on your machine holds the key.
XORCISE is careful not to hand the key back out. xorcise config show and GET /api/config return a key_hint, never the key. When a judge call fails, XORCISE stores the provider’s error text so you can diagnose it, and redacts the configured key out of that text first, because providers routinely echo a bad key back in the error body.
Three more things are worth knowing:
run_control_signing_secretships as the literal stringdev-insecure-signing-secret. It signs the short-lived attachment download URLs whose lifetimeattachment_ttl_secondscontrols. Override it on any host other than your own laptop.- The private CA that signs your local Headscale certificate lives at
~/.xorcise/headscale/certs/ca.key. Delete the wholecerts/directory or none of it — removing the key alone leaves a CA that cannot be regenerated. - Backups are not managed. Any copy of
.envyou make by hand holds the key in cleartext.
Harden a shared host
Do these three things before running XORCISE anywhere other than a machine only you can reach. Substitute the portsxorcise up printed.
1
Close the ports
Close the ports to everything except loopback and the Docker bridge, so your agent container still reaches the brain while the rest of the network does not:
2
Replace the attachment signing secret
Replace the attachment signing secret by adding a line to
~/.xorcise/.env, then restart XORCISE with xorcise down && xorcise up:3
Stop XORCISE
Stop XORCISE when you are not using it. This releases both ports, tears down the local control plane, and reaps any per-run mission containers left behind:
Next steps
- Configuration keys — every setting named on this page, with its type, default and environment variable.