Building Ranges
Overview
A definition (range.yaml, or a typed RangeSpec in Python) declares the networks, the guests, and the guest content; inspect-ranges build realizes that content: it boots the range once, runs the recipes that produce the declared state, verifies it, and captures each guest’s disk as an image; every downstream consumer boots those captured images. The definition language has its own pages:
Ranges: what a range definition is, end-to-end examples in YAML and Python, and the validation workflow.
Guests: hosts, images, resources, routers and the attacker as guests, and guest content such as accounts, services, seeded weaknesses, defense, and Active Directory.
Networks: addressing, DHCP, name resolution, segmentation, firewall rules, routing topology, and egress.
This page covers the machine development runs on, the iteration loop, the build (how declared guest content becomes captured, verified images), and Running the Range: confirming a built range at escalating fidelity. For what happens after a build in production, see Operations.
Development Machine
Ranges build and run on any Linux x86_64 machine with KVM: bare metal with virtualization enabled in firmware, or a VM with nested virtualization. Beyond KVM, the requirements:
Docker Engine 24+ with the Compose plugin (2.22+) and cgroup v2.
Kernel modules
tun,vhost_net, andvhost_vsock.qemu-utilsandlibguestfs-toolsfor building images (libguestfs also requires that the host kernel be readable).br_netfilterunloaded, or loaded withbridge-nf-call-iptables=0, so host iptables never filters bridged range traffic.IPv6 enabled. This one is forward-looking only: IPv6 is not realized yet (every IPv6 construct in a definition refuses validation with
ipv6-not-realized), so no current range needs it;doctorchecks it ahead of future mitm6-style scenarios.
inspect-ranges doctor verifies all of this and prints an exact fix for anything missing.
devbox
The devbox command provisions a remote EC2 instance as a development environment:
Terminal
inspect-ranges devbox up --instance-type m8i.8xlarge
ssh inspect-ranges-devboxThe inspect-ranges devbox up command creates (or starts) a devbox and writes an SSH configuration for it. The default AWS region and profile come from the local AWS configuration; the default instance type is m8i.8xlarge (32 vCPUs / 128 GB). To manage costs, an idle devbox stops itself (after 60 idle minutes by default, with a CloudWatch backstop after 6 hours of low CPU). Reconnecting with ssh or VS Code starts it again: the SSH configuration’s proxy starts the instance and tunnels over AWS SSM, so the devbox has no open inbound ports.
Iteration Loop
Range development is a loop: edit the definition and recipes, validate, plan, build, then confirm the result at escalating fidelity (Running the Range). The authoring steps below illustrate using the dmz_pivot example.
Validate.
Terminal
cd src/inspect_ranges/examples/_dmz_pivot inspect-ranges validate range.yamlinspect ranges validatereports complete diagnostics on the range definition as well as anychallenges.yamlbeside it.Build.
inspect-ranges buildrealizes the declared guest content. Development options include:--keep: holds the build range up for inspection (success or failure);--reverify: forces the full boot-and-verify pass on an unchanged range;--cid-base: picks a different host-global vsock CID block while a kept range still holds the default.
When a stage goes red, start at Diagnosing Failures; a green build is the entry to Running the Range.
Plan.
inspect-ranges planresolves the range into its immutable plan without booting anything: image references against the cache and every address, MAC, and CID allocation. A reference that cannot resolve shows up here, not mid-boot. It sits afterbuildhere because for a range declaring guest content (dmz_pivot does)planrefuses withrange-not-builtuntil a matching build exists; for a topology-only rangeplanworks immediately and slots second.
Running the Range
A green build does not by itself show the range is correct. Three checks confirm it at escalating fidelity, run all three on a new range before it ships.
Poke
Did the build produce the range I meant? Boot the built range directly, with no solver, no eval, and no environment variable:
Terminal
inspect-ranges up --from-spec range.yamlup prints the compose project name (ir-dmz-pivot-...). Attach the range oracle (the privileged guest-access surface scoring uses; see Challenges) by that name from a REPL or script and run commands against any guest, targets included:
import asyncio
from inspect_ranges import range_oracle
async def poke(project: str) -> None:
with range_oracle(project) as oracle:
out = await oracle.exec("db", ["ss", "-ltn"])
print(out.stdout.decode())
creds = await oracle.read_file(
"web", "/home/deploy/.pgpass", user="deploy"
)
print(creds.decode())
asyncio.run(poke("ir-dmz-pivot-..."))exec returns the channel’s ExecOutcome: rc (the signed exit status), stdout/stderr bytes, and truncation flags. The attach rides the same vsock channel the provider uses, so it reaches every guest without touching the range’s network posture, and it logs loudly (an out-of-band oracle attached during a scoring run must be visible in that run’s logs). Confirm what the build claims: the service listening, the planted file in place, the content you declared. Then tear down:
Terminal
inspect-ranges down <project>Smoke Eval
Does the whole consumption path work? Run one sample through the real range sandbox provider early: registration, readiness, challenge setup, and scoring exercise paths a direct boot cannot show.
Terminal
inspect eval inspect_ranges/dmz_pivotSet a wall-clock limit beside the message cap: real-model samples on pivot-shaped ranges have outrun multi-hour windows, and a --time-limit termination still scores, so the run always produces a verdict.
The registry form runs the shipped task; your own range runs the same way with the Range Tasks wiring (Range Sandbox covers the provider).
To exercise boot, planting, scoring, and teardown with no model spend, run against inspect’s mock model: inspect eval task.py --model mockllm/model --message-limit 4. The agent does nothing, so expect a 0 score and the scorer’s zero-submissions warning (plus the matching range_apparatus finding when the scanner below is wired); on this run those show the apparatus working.
Scan
Did the apparatus misbehave? range_apparatus is a deterministic checker for the range plumbing itself: post-readiness channel failures, spec-contradicting reachability, verify_goal wiring trouble, and scoring-integrity flags, each with a remediation hint. Run it online by adding it to the smoke eval’s scorer list, where findings land beside the challenge score in the normal log view:
from inspect_ranges import challenge_scorer
from inspect_ranges.scanners import range_apparatus
scorer = [challenge_scorer(), range_apparatus()]Or scan accumulated logs after the fact:
scout scan inspect_ranges/range_apparatus -T ./logsThe score value is the finding count, so a healthy range reads 0 with explanation no findings. A finding reads like [warning] reachability/declared-unreachable: 10.80.20.10 is declared reachable from the foothold but tool output shows 'no route to host', followed by its hint; the structured findings (detector id, severity, message, hint, event references) ride the score metadata.
Range Build
A definition’s guest content describes converged state: services listening at versions, seeded vulnerabilities, provisioning steps that produce them. None of it is applied at boot. The build realizes it once:
Terminal
cd my-range/ # range.yaml plus ./recipes/
inspect-ranges buildThe range boots on an egress-enabled build plan, recipes converge each guest, every declared service and vulnerability is verified against the live guests, each provisioned guest’s disk is captured as a realized image, and a build manifest records the whole thing. Samples then boot the captured images, byte-identical, with no per-sample provisioning. Declared users, misconfigurations, and data are not independently probed yet: the build trusts the recipes’ success for them, and the behavioral checks confirm whichever of them the attack chains actually depend on. Direct probes for these fields are planned.
Until a range has a matching build, every command that consumes built content refuses with range-not-built: plan, render, up --from-spec, push, and the sandbox provider all require a manifest whose spec hash (a digest of the definition) matches the definition and whose realized images verify by digest in the image cache. The refusal names the fix. Pulling the published build satisfies the gate the same way (Distribution), so consumers of a published range never run build. Nothing boots with silently missing content.
Build Stages
The egress build plan. For the build only, routerless isolated networks become NAT networks with full egress and a host-derived nameserver (
--build-nameserveroverrides it; cloud deployments that force DNS through a VPC resolver typically need the override). A provisioned host attached only to networks behind routers gets a dedicatedbuild-uplinkNAT segment appended instead, so routed topologies build too. A provisioned foothold host (the host the attacker starts on under assumed breach; see Networks) has the attacker’s egress containment lifted for the build only; no agent runs during a build. The runtime spec is untouched: samples boot with the isolation the definition declares, and realized images keep no reference to build networking, since network config is per-boot cloud-init. A per-guest settle gate holds each guest’s first recipe until in-guest name resolution works. Provisioned routers are the one exception: a router gets no build egress (scoped egress on a router-attached network is not realized), so the settle gate skips it and its recipes run offline, reconfiguring what the golden already carries; a router recipe that reaches for the network fails as an ordinary recipe error.Recipes run in declaration order per guest (see Authoring Recipes). Declared
scheduled_activitythen installs as persistent units (a systemd service and timer on Linux, a scheduled task on Windows) from scripts under the range’sactivities/directory (Guests).Verification checks every declared
services,vulnerabilities, andscheduled_activityentry against the live guests, independent of what the recipes claimed (see Verification).Capture. Each provisioned guest is cleanly shut down and its overlay captured into the image cache as a realized image: a qcow2 rebased onto its golden image (Guests), its digest recorded, with a provenance sidecar (a companion file recording the digests of everything it was built from).
The manifest. The builds cache (
~/.cache/inspect-ranges/builds/<range>/manifest.json, orINSPECT_RANGES_BUILD_CACHE) records the spec and its hash, every guest’s build key and realized digest, recipe versions and digests, base-image digests, and the verification results. Consumers and the provider resolve the cache the same way; a non-default location is set through the environment variable (there is no per-verb flag).Behavioral checks, when the range declares them: the captured images boot once more and each challenge’s chains run against the thing that ships (see Behavioral Checks). The manifest lands on disk proven (every check passed) or REFUSED (the failure recorded; consumers reject it with
range-unproven; the next build reuses every guest and re-runs only the checks). A build interrupted before this phase leaves no manifest at all.
Build success also garbage-collects the range’s superseded realized generations: the new generation and the immediately-previous one stay, so reverting an edit rebuilds instantly; anything a running project references is excluded; the deletion is stage-logged with bytes reclaimed. inspect-ranges images prune reclaims the rest (generations stranded by refused manifests or crashes, capture temps, orphan sidecars); images list and doctor report reclaimable bytes.
--keep preserves the build range, on success or failure, for diagnosis; a kept build captures nothing and mints no manifest. A kept range holds its --cid-base vsock CIDs, which are host-global: tear it down before the next build, or rebuild with a different --cid-base. On failure the error names the stage and the guest and points at the recipe log; the range tears down unless kept.
Reading Build Output
A build prints one line per stage transition: the stage name, then sorted key=value fields (verify rows differ: a literal ok and the declaration verified; a failure prints <stage> FAILED <error> instead), and ends with a two-line summary, the manifest path and a build OK line. Abridged, a proven build looks like:
Terminal
resolve rebuilt_guests=2 recipes=2 reused_guests=0
render bundle=... guests=3
ready seconds=41.2
recipe guest=web recipe=dmz.web seconds=201.3 ...
verify ok service web/ssh
verify ok service db/postgres
verified declarations=4
capture file=dmz-pivot-web-ab12cd.qcow2 guest=web
teardown after=success
behavioral-settle seconds=18.0
conformance check=attacker-surface defender=web ports=1-1024+8
behavioral challenge=pivot check=reference detail=value=1.00 ...
behavioral checks=3
manifest guests=2 path=...
build kept=False recipes=2 seconds=1243.7
manifest ~/.cache/inspect-ranges/builds/dmz-pivot/manifest.json
build OK (1243.7 s, 2 recipes) project ir-dmz-pivot-9f1c2a4bThe manifest lines appear after the behavioral lines: a build is never recorded proven before its checks pass. The final build OK line names the compose project (the <project> that inspect-ranges down takes), which is also the build’s directory name under the project state directory (~/.local/state/inspect-ranges/projects/<project>/): stages.jsonl there keeps this history as JSONL, and recipes/ and verify/ hold the per-run logs. A fully cached build prints a single build row and build OK (cached, verified <timestamp>; pass --reverify to re-run).
Incremental Rebuilds
Each guest gets a build key: a hash of its golden image digest, its own spec declarations, its ordered recipe digests with their variables, and its activity scripts’ content digests. On rebuild:
A guest whose key is unchanged boots from its captured realized image with its recipes skipped; verification still covers it in full.
A guest whose recipe, variables, declarations, or golden changed rebuilds from its golden.
When every key matches, the spec hash matches, and the behavioral-check inputs are unchanged, the build is a no-op: nothing boots, and the recorded verification is reported with its timestamp.
--reverifyforces the full boot-and-verify pass anyway.Check scripts and
challenges.yamlare build inputs through their own content digest: editing a check after a green build re-runs only the proof, with every guest reused.
Verification
After all recipes, every declared services and vulnerabilities entry is checked against the live guests over the control channel, whole-range, with one probe per guest:
A service with a
portmust have a TCP or UDP socket listening on it in-guest.A service with a
versionmust match the observed version. A declaredpackage(a dpkg name) is authoritative: its installed version is the observation, and a declared package that is not installed fails outright, naming the listener’s owning package when one exists so the correct declaration is in the failure itself. With nopackage, attribution tries the listener’s process chain (port → pid → exe → owning package), then an exact-name dpkg lookup; the chain is the right default for anything that listens, so omitpackageunless attribution needs forcing. Declared versions match as a component prefix of the upstream version, epochs stripped, so"3.0"matches1:3.0.3+dfsg-1; a pre-release observation (~) never satisfies a release declaration. A declared version that cannot be attributed fails, naming the paths tried.A vulnerability with a
servicereference verifies exactly when its service verifies; one without a reference verifies vacuously and is recorded as such. Exploitability proof lives in behavioral checks, not here.A
scheduled_activityentry must have its installed unit or task present and enabled (systemctl is-enabledon Linux, the scheduled task’s state on Windows); a failure isactivity-not-verified. The unit is verified, never run: an activity may legitimately run long, and the first run happens on the consumer boot’s schedule.
Failures produce coded refusals (service-not-verified, vulnerability-not-verified) with the declared-versus-observed delta and the probe log path, and the build fails. Software installed from tarballs has no dpkg identity: declare its service by port alone and record the version in the range’s notes.
Behavioral Checks
A range that carries challenges (Challenges) can prove them at build time. The build discovers both pieces beside range.yaml: a challenges.yaml, and a checks/<challenge-id>/{reference,insufficient,forged}.sh directory per challenge. Each script runs on the attacker guest as a plain root bash with a 900 s budget per chain (chains legitimately wait for convergence and run full scans). IR_ATTACKER_IP is the only contract variable; target addresses come from the range definition. The script text travels as the command itself, never copied onto the attacker’s filesystem, so the shebang and executable bit are inert and the working directory is unspecified: use absolute paths. A check owns the attack box, including starting listeners for beacons or exfiltrated content to land on (the conformance containment sweep (Security) runs before the checks, so a check’s own listener never trips it). Submissions are SUBMIT <goal-id> <value> lines on stdout, one per submission, the value running to the end of the line; a value that contains an exfiltrate canary passes, so single-line excerpts of multi-line content work. Chain stdout is capped at 16 MiB and truncation is a hard check failure, since SUBMIT lines may be lost. A failing chain’s output lands in the build’s stage log (stages.jsonl under the project state directory; the failing behavioral row carries the chain’s exit status and the tail of its stderr). Each check runs against fresh per-sample plants, removed afterward. The shipped dmz-pivot checks are the worked example. The contract:
| Chain | Must |
|---|---|
reference.sh |
Exercise the real attack chain and score 1.0 with validity valid; every goal that declares a beacon_segment must have its beacon found in that segment’s packet capture (Goal Types). |
insufficient.sh |
Do real but incomplete work and score 0.0 with validity valid and at least one submission. |
forged.sh |
Submit plausible fakes and score 0.0 with validity valid and at least one submission. A chain that submits nothing, or a scorer that never reached a verdict, would otherwise pass vacuously. |
The scores are exact, which pins down “incomplete” for a multi-goal challenge: scoring is a weighted fraction, so any genuinely achieved goal makes the insufficient chain’s score nonzero and fails the check. Keep that chain to real work that achieves nothing (recon, partial access, a plausible wrong submission). Corroboration gates the reference chain only where a goal declares beacon_segment. An exfiltrate goal’s segment does not gate the trio: an uncorroborated pass records corroborated: false rather than failing.
The one-submission floor applies only when the challenge has submission-consuming goals: a challenge scored purely by observation (detection, evade) has nothing to submit, so its insufficient and forged chains need only exit 0 and score 0.0 with validity valid (the splunk-detection checks are the worked example). Prober-backed goals score in the trio too: each check runs its own prober, and the chain must keep running until the goal’s window_s is covered (Challenges).
Model-graded (judge) goals are exempt from the trio’s scoring: the build has no model, so each check scores the challenge over its non-model-graded goals (the weighted mean renormalizes over that share, and the floor reads it too) and records the exemption as judge-exempt=<goal ids> in the check’s stage line and manifest detail. A challenge whose goals are all model-graded has no provable share and refuses with challenge-not-provable.
Beacons corroborate once they cross the wire: scoring flushes the still-active capture chunk before reading, so a beacon no longer waits for its chunk to rotate. Only the separated (uds) capture topology, where the flush is a recorded follow-up, still depends on the chunk rotating before scoring. The build’s proof boot (the post-capture boot that runs the checks) defaults to 1 MB capture chunks, and the reference chain pushes filler traffic after its beacons (the vulhub chain is the worked example).
The checks run against the captured realized images, in a second boot after capture, through the same consumption path samples use, so they prove the artifact that ships. Before the checks, the same boot runs the conformance stage, proving the range’s defender-to-agent containment (Security); a containment finding refuses the manifest exactly like a failed check.
Custom goal types parse at build time: the build loads the inspect_ai entry-point registrations before parsing challenges.yaml (Custom Goals).
Each check gets fresh per-sample proof material, removed afterward; nothing a check planted survives as a valid secret at runtime. A declared checks tree must be complete: a misnamed directory or missing script refuses rather than silently skipping. A failing check saves a REFUSED manifest: consumers refuse it with range-unproven, while the next build reuses every guest and re-runs only the proof, so fixing a check script costs a boot-and-prove cycle, not a rebuild. The build reports a behavioral stage line per check and records the results in the manifest.
Diagnosing Failures
Every failure names its stage (recipe, activity, verify, capture, manifest, behavioral, teardown) and, where it has one, the guest. Recipe output lands in one log per run under the project state directory (~/.local/state/inspect-ranges/projects/<project>/recipes/); the error carries the path and a tail, and stages.jsonl in the same directory records every stage transition with its error field. --keep holds the failed range up for inspection (inspect-ranges down <project> when done). inspect-ranges doctor checks the build surface up front: cache writability, recorded manifests and cached recipe bundles still parse, and whether a build nameserver is derivable from this host.