Skip to main content
XORCISE runs deliberately vulnerable software on your machine and exposes an unauthenticated API to drive it. Both are deliberate, and together they decide where you should and should not run XORCISE.

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 fixed container_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. Its internal flag would be silently overwritten.
  • A mission declaring an external: true network. Compose did not create it, so internal is a no-op on it.
A mission author can opt the whole mission out by setting 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 internal flag 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.
If you need a guarantee rather than a confinement, cut egress yourself at the host or network level. XORCISE narrows the path; it does not replace your firewall.

Open ports and no authentication

In the default local 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 beyond loopback and the bridge gateway in three cases. Setting XORCISE_HOST=0.0.0.0 widens it to the IPv4 wildcard 0.0.0.0. Setting XORCISE_HOST to any other non-loopback address, such as 192.168.1.5, adds that address to the bind alongside loopback and the bridge gateway, which puts both planes on that interface without a wildcard. And on Linux, when XORCISE cannot determine a bindable Docker bridge gateway at boot, the bind falls back to the wildcard, because a loopback-only bind would break every run rather than lock anything down. The host setting defaults to 127.0.0.1. It is the address XORCISE prints and health-polls, and a value other than loopback also widens the bind as described above. Neither plane authenticates.
  • The operator API — /api/runs, /api/agents, /api/config, /api/missions, /api/catalog and /api/system — accepts any caller.
  • /api/fs/list is 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 a 403.
  • 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. On the machine your agent runs on, XORCISE keeps it off process command lines, where any local user could read it with ps — see Keep the token off command lines.
  • The OTLP receiver’s /v1/traces and /v1/logs take no credential at all. Runs are separated by the xorcise.run_id resource attribute alone, and a run id is not a secret. A batch that names a run that has not started yet starts it, so a run stopped afterwards is graded rather than ending cancelled. See What starts a run.
The practical consequence: anyone who can reach the port can read your runs, results and traces, rewrite your configuration, and create runs against your Docker daemon. Under the default bind that means every process on your machine and every container on the Docker bridge — not a small set, and none of it authenticated. Widen the bind and it means the network too, so on a shared, untrusted or public network, do not leave XORCISE up.
The REST API, the web UI and the OTLP receiver require no credential. By default they are reachable from your own machine and from any container on the Docker bridge; set XORCISE_HOST to 0.0.0.0 or another non-loopback address and they are reachable from the network as well. Anyone who can reach those ports can read your runs and traces, change your configuration, and start runs. Firewall the ports, or run xorcise down when you are not using XORCISE.
Both planes auto-increment across a 50-port window when their default port is busy, so firewall the ports xorcise up printed rather than assuming 3001 and 4318.

What leaves your machine

XORCISE is local by default, not local unconditionally. Five 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. The status bar needs nothing from you either. Every page polls /api/system every 15 seconds, and that call checks the catalog’s status and the mission base release it promotes, each through a cache of 120 seconds, so the catalog is contacted at most about once every two minutes while a tab is open. xorcise doctor queries the catalog the same way for its mission base check. The announcement check needs nothing from you beyond opening the web UI, whether or not you ever visit the library. When a browser loads the console, it asks the local server, and the local server, never the browser, sends GET /v1/announcements/active to catalog_url. The request carries the HTTP client’s default headers and nothing else: no identifier, no cookie, no key. It is not a usage beacon, but the catalog does see it arrive, from your address. The XORCISE process keeps the answer for two minutes, so further tabs and reloads inside that window are served from memory, and the cache is lost when the process restarts. A reply larger than 64 KiB is refused. The fetch runs in the background, and the request that asked for it waits at most five seconds. If the catalog has not answered by then, the local server returns the last announcements that catalog gave, or none, and keeps the late reply for the next load. Only one fetch per catalog runs at a time, so a catalog that answers slowly holds one connection open, not one per tab or reload. The rest of the console does not wait on the request. An offline or air-gapped install, an unreachable catalog, a malformed reply, and a catalog too old to serve announcements all give an empty list and no banner; an unreachable catalog is logged at debug level only. The console renders banner text itself as a restricted Markdown subset, never as raw HTML, and a banner link can point only to an https:// or site-relative address. 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 XORCISE keeps — nothing else it writes holds the key. How the key gets there matters as much. Set it with --key-stdin on xorcise config set-model and xorcise config set-terrain-model: on a terminal it prompts without echo, and in a script it reads a pipe, as in printf %s "$KEY" | xorcise config set-model --key-stdin. The --key <value> form still works, but it puts the key on the command line, and argv is not private. Your shell writes the whole command to its history file, and any local user can read it through ps or /proc/<pid>/cmdline while the command runs. A key set that way is no longer only in .env. The CLI also refuses a key read from stdin that is empty, which would otherwise clear the stored key, or that spans more than one line. The server refuses a line break too, for any client. PUT /api/config/model, PUT /api/config/terrain-model and PUT /api/config/network answer 422 when any text field contains one, before anything is written, because .env stores each value unquoted on one line. The error names the field, never its value. 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_secret is unset by default, and XORCISE generates a fresh random secret at every process start. It signs the short-lived attachment download URLs whose lifetime attachment_ttl_seconds controls, so a link minted before a restart stops verifying. Set it only to keep links valid across restarts or to share one secret between processes.
  • The private CA that signs your local Headscale certificate keeps its key at ~/.xorcise/headscale/ca/ca.key, in a directory only you can read. No container mounts it: the Headscale container gets certs/, which holds the CA certificate and the server’s own certificate and key, and each run’s subnet router gets the CA certificate alone. Installs from before 0.2.1 kept the key in certs/, and the first xorcise up after the upgrade moves it, keeping the same CA. If the key is lost or no longer matches the certificate, the next up mints a new CA and re-signs the server certificate, and runs in flight lose their trust in the control plane.
  • Backups are not managed. Any copy of .env you make by hand holds the key in cleartext.

What the evidence seal proves, and what it does not

When a run is sealed, XORCISE records a SHA-256 digest of the evidence it grades: the raw trace and log batches, the run-control record including the agent’s artifacts, and the observed facts. The run report, xorcise run status and GET /api/runs/{run_id}/result?verify=true hash that evidence again and compare. How grading works explains each answer. The seal detects a change to that evidence made after sealing, by anyone who leaves the stored digest alone. A batch, artifact or observed fact edited, added or deleted in ~/.xorcise/xorcise.db makes the next check answer mismatch. It does not detect:
  • Someone who rewrites the digest too. The digest is a plain SHA-256 with no key, stored in the same database file as the evidence, in the trace_seals table. It is not a signature. Anyone who can edit the evidence can recompute the digest and write it back, and the run then reads verified. XORCISE itself never overwrites a recorded digest, but a direct write to the database is not bound by that.
  • Someone who deletes the digest. A run with no digest reads none, the same as a run sealed before XORCISE recorded digests. A digest replaced with one under a scheme this build does not implement reads unverifiable. Neither looks like tampering.
  • Changes to the result. The score, the check and criterion breakdowns, the judge’s reasons and the conditions are outside the digest, and so are the run’s own metadata and the time each batch was received. An edited score still reads verified.
  • Evidence that was false when it arrived. The seal shows that the evidence has not changed since sealing, not that it is true. Telemetry is whatever the agent’s harness exported, and the OTLP receiver takes no credential, so any process that knew the run id could have added batches before the seal. Those batches are inside the digest like everything else.
A mismatch is reported, not enforced. A re-grade logs a warning and grades the run anyway, and exports carry the run as usual; the report puts a warning above the scores. The answer also says only that the covered evidence changed, not what changed or who changed it, and only the XORCISE instance holding the evidence can check it: nothing verifies an exported bundle against its digest.

Harden a shared host

Do these three things before running XORCISE anywhere other than a machine only you can reach. Substitute the ports xorcise 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

Pin the attachment signing secret (optional)

By default XORCISE generates a new attachment signing secret at every start. To keep attachment links valid across restarts, pin it 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.