Skip to content

feat(inspect-ai): add host-side Dockerfile and Compose build image support - #340

Draft
tholop wants to merge 3 commits into
feat/inspect-capsem-containersfrom
feat/inspect-capsem-host-build
Draft

tholop wants to merge 3 commits into
feat/inspect-capsem-containersfrom
feat/inspect-capsem-host-build

Conversation

@tholop

@tholop tholop commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds an opt-in, operator-granted host docker build path (HostBuildGrant) to inspect-capsem so Inspect AI evaluations that ship a Dockerfile or Compose build: stanza can build images on the host with BuildKit, cache them by content hash, and serve them over a loopback HTTPS OCI v2 registry to Capsem 0.7's existing Hypervisor.create(image="...@sha256:...") puller (#342 step 4). This PR only touches integrations/inspect-ai, web/docs, and the hermetic live gate fixture (tests/ironbank/test_sdk_live.py, tests/helpers/sdk_packages.py); it adds no new routes or container-build machinery to capsem-service, the guest runtime, or the SDKs. Stacked on #339.

Changes

  • Default-off operator grant & Compose build: parsing (build_grant.py, compose_fields.py):
    • Host docker build is off by default and runs only when the operator sets CAPSEM_INSPECT_HOST_BUILD=1 in the evaluator process environment (0 is an unconditional hard deny). Task configs (CapsemSandboxConfig.host_build: HostBuildGrant | bool | None) can only narrow the operator's environment settings for a task or sample via HostBuildGrant(enabled: bool = True, allowed_contexts: tuple[str, ...] = (), network: Literal["none", "default"] | None = None, ca_pem: bool = True), never widen them.
    • Normalizes top-level dockerfile: / build: strings and rejects remote URLs via prepare_compose_build_service, then delegates Compose build: field extraction to 0.7's compose_service.extract_compose_fields (dockerfile, build_context, build_args, build_target), preserving its input bounds and documented refusals (dockerfile_inline, build.secrets, ssh, privileged, network, cache_from, cache_to, extra_hosts, additional_contexts, and unknown build: keys).
  • Context, credential, network & socket isolation (build_context.py, image_build.py):
    • Requires both os.path.realpath(context_dir) and os.path.realpath(dockerfile) under CAPSEM_INSPECT_ALLOWED_HOST_PATHS (the Compose file directory is not an implicit build root). .dockerignore is honoured for symlink containment (refusing any non-ignored escaping symlink before docker build), while compute_build_cache_key hashes all regular files in context_dir so .dockerignore matcher differences cannot cause stale cache hits.
    • Runs docker build with --network none by default (CAPSEM_INSPECT_BUILD_NETWORK=none|default; network="host" is always refused) and an isolated temporary --config directory ({"auths":{}}) unless CAPSEM_INSPECT_BUILD_DOCKER_CONFIG explicitly grants an operator directory under CAPSEM_INSPECT_ALLOWED_HOST_PATHS (ambient ~/.docker/config.json is never read).
    • Refuses any volume bind whose host source (or resolved symlink target) or container target basename is docker.sock.
  • BuildKit, CA staging & thread-safe loopback OCI registry (build_ca.py, dockerfile_ca.py, oci_ingest.py, oci_registry.py):
    • Requires Docker BuildKit on the host (the default since Docker 23.0; DOCKER_BUILDKIT=0 is refused before docker build), passing RUN <<EOF and COPY <<EOF heredocs straight through to BuildKit and removing the legacy 0.6 classic-builder heredoc module (dockerfile_heredoc.py).
    • Stages operator-granted CA files (CAPSEM_INSPECT_BUILD_CA_PEM_FILE and CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE, required when combining CA injection with network="default") into a temporary copy of the build context for non-scratch stages and includes the CA SHA-256 fingerprint in the build cache key.
    • Serializes concurrent builds per cache_key (threading.Lock()), writes cached OCI blobs via unique temporary files before atomic rename, mints loopback TLS certificates atomically under a cross-process fcntl.flock (.tls.lock) + threading.Lock(), verifies cached blob SHA-256 digests on every lookup, serves blobs over a read-only loopback HTTPS OCI v2 registry (127.0.0.1:5055) by @sha256: digest only, and relies directly on 0.7's guest container launcher for runtime USER, WORKDIR, and ENV.

Authority Boundary (#342 Step 4)

Because 0.7 does not run an in-guest image builder, building a task's Dockerfile runs on the host Docker daemon outside the micro-VM boundary. The operator enables builds and sets limits via environment variables on the evaluator process; tasks can only narrow those limits, and inspect-capsem never reads ambient ~/.docker credentials or edits ~/.capsem/settings.toml:

Dimension Operator run grant (env var, required) Task narrowing (CapsemSandboxConfig.host_build=HostBuildGrant(...)) Default when absent / unsupported
Execution CAPSEM_INSPECT_HOST_BUILD=1 (0 = hard deny) host_build=False or HostBuildGrant(enabled=False) disables for this task Refuse (ValueError naming CAPSEM_INSPECT_HOST_BUILD and the stanza that needs it)
Context root CAPSEM_INSPECT_ALLOWED_HOST_PATHS must contain realpath(context_dir) and realpath(dockerfile) allowed_host_paths / host_build.allowed_contexts narrow (never widen); Compose dir is not implicit Refuse
.dockerignore / symlinks .dockerignore honoured for symlink containment while cache key hashes all regular context files (Dockerfile and .dockerignore realpaths validated directly); non-ignored escaping symlink refused before docker build — Refuse escaping symlinks
Network CAPSEM_INSPECT_BUILD_NETWORK=none|default sets the limit host_build.network can only narrow (default -> none allowed; none -> default refused) none (--network none); host always refused
Registry credentials CAPSEM_INSPECT_BUILD_DOCKER_CONFIG=<dir> under CAPSEM_INSPECT_ALLOWED_HOST_PATHS (operator-env-only) Not settable by task HostBuildGrant Isolated temp docker --config with {"auths":{}}; ~/.docker never read
Build secrets & unsupported keys Normalized via prepare_compose_build_service + compose_service.extract_compose_fields — dockerfile_inline, build.secrets, ssh, privileged, network, cache_from, cache_to, extra_hosts, additional_contexts → documented refusal
CA trust CAPSEM_INSPECT_BUILD_CA_PEM_FILE=<path> (+ required CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE=<path> when network="default") under CAPSEM_INSPECT_ALLOWED_HOST_PATHS (operator-env-only paths) host_build.ca_pem: bool = True (ca_pem=False opts out of CA patching for the task) No Dockerfile CA patching
Docker socket Any bind whose host source (or resolved symlink target) or container target basename is docker.sock → refuse — Refuse
Service admission (~/.capsem/settings.toml) Operator configures 0.7 [images] selectors once:
sources = ["127.0.0.1:5055"]
admit = ["127.0.0.1:5055/inspect-capsem/build"] (or exact @sha256: pins)
Every Hypervisor.create passes @sha256:<manifest> + Registry(ca_pem=...) Service 400/403 → controller raises with both [images] lines verbatim; integration never edits settings

Qualification & Alternatives Considered (#342)

  • Fast unit & container boundary suite (integrations/inspect-ai/tests): ruff check, ruff format --check, and ty check --error-on-warning --python-platform all PASS (0 findings); pytest integrations/inspect-ai/tests -q: 101 passed (95% coverage) across test_build_grant.py, test_build_context.py, test_image_build.py (including 4-thread concurrent build singleflight + blob ingest regression), test_oci_registry.py, test_build_ca.py, and test_dockerfile_ca.py.
  • Hermetic installed-package live VM + OCI + host-build gate (tests/ironbank/test_sdk_live.py::test_inspect_ai_live_vm_sandbox_acceptance, 260.37s): runs tests/host_build_acceptance.py + tests/host_build_fixture.py from both offline-installed wheel and sdist under python -I (INSPECT_CAPSEM_HOST_BUILD_ACCEPTANCE_OK), verifying default-off and widened-grant refusals, escaping-symlink refusal, offline multistage docker build --network none (with .dockerignore, --build-arg, --target, FROM scratch, RUN <<EOF & COPY <<EOF heredocs, COPY --chmod/--chown, ADD archive unpack, non-root USER 1000:1000, WORKDIR, ENV, ENTRYPOINT/CMD, tool-bundle 0700 upload, exec(user="root") -> PermissionError, and 34/34 portable inspect_self_check checks), real openssl CA staging, second-run cache hit without rebuilding, session.db exec_events (target="workload"), and 0 leaked VMs.
  • Live benchmarks & 4-way concurrency qualification:
    • inspect_evals/vimgolf_single_turn --limit 2 (2/2 samples, 0 errors), inspect_evals/gdm_intercode_ctf --limit 2 (sample_ids=[0, 2], Compose build: . with network_mode: bridge under CAPSEM_INSPECT_HOST_BUILD=1, 2/2 samples, 0 errors), and inspect_harbor/terminal_bench_2 (1/1 sample, 0 errors), all with 0 leftover VMs.
    • 4-way concurrent Compose build: (max_sandboxes=4, 4 samples, 400 MiB and 1 GiB uncompressed layers): cold cache runs docker build once while 3 threads wait on _lock_for_cache_key (0 blob temp-file collisions); warm cache runs 0 docker build calls (4/4 cache hits); 0 leaked VMs across all runs (see feat(inspect-ai): add OCI container execution mode and Compose parser #339 Known Limitations 1 & 2 for capsem-service's 30s oci-cache-lock contention when creating 4 VMs from the same large image concurrently).
  • Alternative — native OCI archive import in capsem-service (0.7: Refactor and land Inspect Compose/container support #311 / 0.7: Complete service image/cache and managed ephemeral-session contracts #301): a POST /images/import route (or local OCI-layout reference) that ingests a docker image save archive straight into capsem-assets's host blob cache would remove the loopback HTTPS registry, ephemeral TLS certificate generation, and 127.0.0.1:5055 [images] source configuration. We are happy to wait for or help build native OCI import if you prefer that over the loopback registry bridge; this PR uses Hypervisor.create(image="...@sha256:...") so it requires zero changes to capsem-service.

@codecov-commenter

codecov-commenter commented Oct 7, 2026 •

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
6052 1 6051 0
View the top 3 failed test(s) by shortest run time
capsem-assets::oci::cache::inventory::tests::reused_materialization_controls_invalidate_inventory_on_acquire_and_after_release
Stack Traces | 0.199s run time
thread 'oci::cache::inventory::tests::reused_materialization_controls_invalidate_inventory_on_acquire_and_after_release' (125624) panicked at .../cache/inventory/tests.rs:130:39:
called `Result::unwrap()` on an `Err` value: cache changed during inventory observation
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
capsem-assets::oci::worker::tests::inventory_worker_publishes_only_quiet_bounded_observations_and_tracks_nested_changes
Stack Traces | 2.34s run time
thread 'oci::worker::tests::inventory_worker_publishes_only_quiet_bounded_observations_and_tracks_nested_changes' (130168) panicked at .../oci/worker/tests.rs:27:6:
called `Result::unwrap()` on an `Err` value: TimedOut { label: "inventory-observed", attempts: 8, timeout: 2s }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
capsem-assets::oci::worker::tests::incompatible_receipts_are_observed_once_until_their_metadata_changes
Stack Traces | 2.64s run time
thread 'oci::worker::tests::incompatible_receipts_are_observed_once_until_their_metadata_changes' (130110) panicked at .../oci/worker/tests.rs:246:10:
called `Result::unwrap()` on an `Err` value: TimedOut { label: "foreign-cache-reobserved", attempts: 8, timeout: 2s }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
capsem-core::fs_monitor::tests::a_workspace_swapped_for_a_host_link_is_never_walked_or_read
Stack Traces | 360s run time
No failure message available

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

ebursztein added a commit that referenced this pull request Oct 7, 2026
Link assigned issue #342 and source PRs #338, #339 and #340. Keep completed parser components available while removing Inspect VM/workload and Dockerfile/Compose phases from this agent sprint. Shared 0.7 includes all carried code and latest main; complete integration and runtime/package qualification remain Pierre’s work.
tholop added a commit that referenced this pull request Oct 8, 2026
Add OCI container execution mode (`execution_mode="container"` / `execution_mode="auto"`, `image=...`, and `compose.yaml` / `docker-compose.yml` config parsing) on top of Capsem VM sandboxes:

- `inspect_capsem/containers/compose.py` + `compose_fields.py` + `compose_inputs.py` + `compose_interpolation.py` + `compose_service.py` + `compose_values.py`: single bounded Compose parser built on `ComposeInputs`, `ComposeLimits` / `_BoundedSafeLoader`, and `InterpolationBudget` with redacted diagnostics, default-deny host environment interpolation (`SAMPLE_METADATA_*` allowlist with `.env` spoofing rejection), support for `image`, `command`/`entrypoint`, `environment`, `working_dir`, `user`, `volumes` (read-write and `:ro` bind mounts with symlink and project-root containment), `healthcheck`, `cpus`/`mem_limit`, and fail-closed validation on unsupported service keys, network isolation overrides, and multi-service topologies.
- `inspect_capsem/containers/runtime.py` + `controller.py`: OCI container staging (`prepare_oci_workload_container`, `_stage_oci_bind_volumes`) and `ContainerController` protocol.
- `inspect_capsem/_compose.py`, `_controller.py`, `_exec.py`, `_files.py`, `_lifecycle.py`, `config.py`, `sandbox.py`: thread container execution mode, non-root `user` execution (`su -m` / `setpriv`), `/workspace` staging, and `SdkCapsemController` `registry_ca_pem` / `Registry(ca_pem=...)` plumbing (moved from PR #340 into PR #339 so #339's hermetic loopback TLS OCI workload fixture authenticates without #340 while `CapsemSandboxConfig` continues to reject `registry_ca_pem` in untrusted task configs).
- Unit tests (`tests/containers/*`, `tests/test_config.py`, `tests/test_sandbox*.py`) and hermetic loopback TLS OCI workload acceptance (`tests/oci_workload_fixture.py`, `tests/live_acceptance.py`).

Proves #310 / #311 / #342 acceptance criteria:
- [x] Compose config coercion (`compose.yaml` / `docker-compose.yml`) and `CapsemSandboxConfig(image=...)` select `execution_mode="container"` and route `exec` to `ExecTarget.WORKLOAD`.
- [x] Hermetic OCI workload acceptance (`tests/oci_workload_fixture.py` + `tests/live_acceptance.py`) builds a digest-pinned OCI image from the guest initrd busybox, serves it over loopback TLS with a per-run CA passed via `SdkCapsemController(registry_ca_pem=...)`, admits its digest in `settings.toml`, runs `sample_init`, `exec` (`ExecTarget.WORKLOAD`), `write_file`/`read_file` (text and binary), and `eval_async` with `SandboxEnvironmentSpec("capsem", ...)` without pulling from Docker Hub, and verifies `history(layer=EXEC)` + `session.db` `exec_events` (`target="workload"`) and zero leaked VMs after `sample_cleanup`.
@tholop
tholop force-pushed the feat/inspect-capsem-containers branch from 99a088b to 3cb33cd Compare October 8, 2026 13:07
tholop added 3 commits October 9, 2026 14:34
…ck OCI registry

Add evaluator-granted host-side Dockerfile and Compose `build:` image building
and a read-only loopback HTTPS OCI v2 registry to `inspect-capsem-sandbox`:

- Add `HostBuildGrant` (`containers/build_grant.py`) enforcing evaluator
  environment authority (`CAPSEM_INSPECT_HOST_BUILD=1`,
  `CAPSEM_INSPECT_ALLOWED_HOST_PATHS`, `CAPSEM_INSPECT_BUILD_NETWORK`,
  `CAPSEM_INSPECT_BUILD_DOCKER_CONFIG`) with task-level narrowing and strict
  refusal of `--network host`.
- Add `containers/build_context.py` for canonical path containment, symlink
  escape rejection, `.dockerignore` context tree walking, deterministic cache
  key hashing, and unified Compose `build:` / `dockerfile:` normalization.
- Add `containers/image_build.py`, `containers/oci_ingest.py`, and
  `containers/oci_registry.py` to build OCI archives via `docker build` /
  `buildx`, ingest OCI-layout and legacy `docker-save` tars into a content-
  addressed blob store (skipping BuildKit attestation manifests), and serve
  verified digests over a loopback TLS 1.2+ `/v2/` registry with 0.7 `[images]`
  `sources`, `ca_pem`, and `admit` profile wiring.
Stage .capsem-ca.crt and .capsem-ca-bundle.crt into a temporary build
context when CAPSEM_INSPECT_BUILD_CA_PEM_FILE (and optional
CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE) is set, seed NSS sql:$HOME/.pki/nssdb
via certutil during builds and container startup, require BuildKit on the
host (removing the legacy 0.6 classic-builder heredoc lowering module), and
include the CA fingerprint in build cache keys.
Extend `run_live_vm_sandbox_acceptance` (invoked by the live VM gate
`test_inspect_ai_live_vm_sandbox_acceptance`) with
`verify_host_build_workload_mode` (`#342` bullets 2, 5, and 6):

- Require the `docker` CLI and daemon on `PATH` and fail loudly if
  absent (no silent skip; `#342` bullet 5).
- Load a hermetic busybox base image from the guest initrd into local
  Docker and build a multi-stage `Dockerfile` + `compose.yaml` (`build:`
  with `context`, `dockerfile`, `target`, and `args`) completely offline
  (`CAPSEM_INSPECT_BUILD_NETWORK=none`).
- Verify fail-closed refusals when `CAPSEM_INSPECT_HOST_BUILD` is unset,
  when a symlink escapes the build context, and when `HostBuildGrant`
  attempts to widen `allowed_contexts` or `network` beyond the operator
  environment ceiling.
- Verify multi-stage `FROM ... AS ...`, `FROM scratch`, `ARG`/`ENV`,
  `WORKDIR`, `USER 1000:1000`, `ENTRYPOINT`/`CMD`, `COPY --from` with
  `--chown`/`--chmod`, heredoc `RUN`/`COPY`, `ADD` local tar extraction,
  `.dockerignore` exclusion, real `inspect-sandbox-tools` host binary
  upload (`700:1000:1000`) and onedir archive extraction as UID
  `1000:1000` (`RUN rm -rf /var/tmp/sandbox-services` before
  `USER 1000:1000`), and `CAPSEM_INSPECT_BUILD_CA_PEM_FILE` CA injection
  (including byte-for-byte PEM equality inside the live guest container)
  on a real VM workload container (`#342` bullets 2 and 6).
- Verify non-root `USER 1000:1000` container file round-trip (`write_file`,
  in-container append, `read_file`), `PermissionError` on `user="root"`
  privilege escalation under `no-new-privileges`, and `inspect_ai`'s
  portable `self_check` suite against the `USER 1000:1000` container.
- Pin a stable loopback OCI registry port (preferring default `5055` via
  `CAPSEM_INSPECT_BUILD_REGISTRY_PORT` and falling back to an available
  loopback port if `5055` is busy) across `settings.toml` `[images]`
  source admission and verify second-sample digest cache reuse without a
  second `docker build` even after tightening `settings.toml` to
  digest-only admission.
- Load sibling test modules in standalone `python -I` gateway consumer
  runs via `importlib.util.spec_from_file_location` without mutating
  `sys.modules["tests"]`, and use `unittest.mock.patch.object` for test
  seam overrides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants