From 6ea58de4d88eeb232468b1848208db4cf9c48286 Mon Sep 17 00:00:00 2001 From: Pierre Tholoniat Date: Fri, 9 Oct 2026 08:25:23 +0000 Subject: [PATCH] feat(inspect-ai): add OCI container execution mode and Compose parser Add OCI container execution mode (`execution_mode="container"`, `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`, skipping privilege switching when already running at target UID/GID inside non-root workloads and raising `PermissionError` when requesting a root or different user switch inside a `no-new-privileges` non-root workload), `/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`. --- CHANGELOG.md | 7 +- .../inspect-ai/inspect_capsem/_compose.py | 107 +++++++ .../inspect-ai/inspect_capsem/_controller.py | 41 ++- .../inspect-ai/inspect_capsem/_exec.py | 86 ++++-- .../inspect-ai/inspect_capsem/_files.py | 38 ++- .../inspect-ai/inspect_capsem/_lifecycle.py | 13 + .../inspect-ai/inspect_capsem/_transfer.py | 16 +- .../inspect-ai/inspect_capsem/config.py | 120 ++++++-- .../inspect_capsem/containers/__init__.py | 29 ++ .../inspect_capsem/containers/compose.py | 40 ++- .../containers/compose_fields.py | 237 ++++++++++++++++ .../containers/compose_inputs.py | 147 +++++++++- .../containers/compose_interpolation.py | 21 +- .../containers/compose_service.py | 12 +- .../containers/compose_values.py | 93 +++++- .../inspect_capsem/containers/controller.py | 27 ++ .../inspect_capsem/containers/runtime.py | 217 ++++++++++++++ .../inspect_capsem/containers/spec.py | 22 ++ .../inspect-ai/inspect_capsem/sandbox.py | 59 +++- integrations/inspect-ai/pyproject.toml | 1 + .../inspect-ai/tests/containers/__init__.py | 1 + .../tests/containers/test_compose.py | 186 ++++++++++++ .../tests/containers/test_compose_host_env.py | 187 ++++++++++++ .../tests/containers/test_compose_options.py | 61 ++++ .../tests/containers/test_runtime.py | 94 +++++++ integrations/inspect-ai/tests/helpers.py | 5 +- .../inspect-ai/tests/live_acceptance.py | 84 +++++- .../inspect-ai/tests/oci_workload_fixture.py | 254 +++++++++++++++++ .../inspect-ai/tests/test_compose_config.py | 138 +++++++++ integrations/inspect-ai/tests/test_config.py | 101 ++++++- .../tests/test_containers_boundary.py | 79 ++++++ .../inspect-ai/tests/test_controller.py | 6 + .../inspect-ai/tests/test_controller_exec.py | 43 +-- integrations/inspect-ai/tests/test_sandbox.py | 32 ++- .../tests/test_sandbox_container.py | 265 ++++++++++++++++++ .../inspect-ai/tests/test_sandbox_exec.py | 17 +- .../inspect-ai/tests/test_transfer.py | 9 + integrations/inspect-ai/uv.lock | 2 + tests/ironbank/test_sdk_live.py | 3 + web/docs/src/content/docs/usage/inspect-ai.md | 33 ++- 40 files changed, 2794 insertions(+), 139 deletions(-) create mode 100644 integrations/inspect-ai/inspect_capsem/_compose.py create mode 100644 integrations/inspect-ai/inspect_capsem/containers/__init__.py create mode 100644 integrations/inspect-ai/inspect_capsem/containers/compose_fields.py create mode 100644 integrations/inspect-ai/inspect_capsem/containers/controller.py create mode 100644 integrations/inspect-ai/inspect_capsem/containers/runtime.py create mode 100644 integrations/inspect-ai/inspect_capsem/containers/spec.py create mode 100644 integrations/inspect-ai/tests/containers/__init__.py create mode 100644 integrations/inspect-ai/tests/containers/test_compose.py create mode 100644 integrations/inspect-ai/tests/containers/test_compose_host_env.py create mode 100644 integrations/inspect-ai/tests/containers/test_runtime.py create mode 100644 integrations/inspect-ai/tests/oci_workload_fixture.py create mode 100644 integrations/inspect-ai/tests/test_compose_config.py create mode 100644 integrations/inspect-ai/tests/test_containers_boundary.py create mode 100644 integrations/inspect-ai/tests/test_sandbox_container.py diff --git a/CHANGELOG.md b/CHANGELOG.md index afef3981b..64626be7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1269,8 +1269,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `integrations/inspect-ai` (`inspect-capsem-sandbox`) provides a standalone [Inspect AI](https://inspect.aisi.org.uk/) `SandboxEnvironment` registered - under `capsem`, supporting direct VM execution - backed by the Capsem Python gateway SDK (`capsem>=0.7.0`). + under `capsem`, supporting direct VM execution (`execution_mode="vm"`) and + rootless OCI workload container execution (`execution_mode="container"`) + with single-service Docker Compose support, `SAMPLE_METADATA_*` + interpolation, and operator-owned host environment and bind-mount + allowlists, backed by the Capsem Python gateway SDK (`capsem>=0.7.0`). - The profile catalog names its own defaults, one per runtime: the binary compiles them from `config/profile-catalog.toml` (a runtime's default diff --git a/integrations/inspect-ai/inspect_capsem/_compose.py b/integrations/inspect-ai/inspect_capsem/_compose.py new file mode 100644 index 000000000..fe1808cd0 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/_compose.py @@ -0,0 +1,107 @@ +"""Inspect-facing `CapsemSandboxConfig` coercion and Compose file resolution.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from inspect_ai.util import ComposeConfig, SandboxEnvironmentConfigType + +from .config import CapsemSandboxConfig + + +def _is_dockerfile_string(s: str) -> bool: + name = Path(s).name.lower() + return ( + name in ("dockerfile", "containerfile") + or name.startswith(("dockerfile.", "containerfile.")) + or name.endswith((".dockerfile", ".containerfile")) + ) + + +def _validate_direct_volumes(cfg: CapsemSandboxConfig) -> CapsemSandboxConfig: + if cfg.volumes: + from .containers import normalize_volumes + + normalize_volumes(cfg.volumes, None, cfg.allowed_host_paths) + return cfg + + +def resolve_compose_file( + cfg: CapsemSandboxConfig, *, sample_metadata: Mapping[str, Any] | None = None +) -> CapsemSandboxConfig: + """Resolve `cfg.compose_file` and return an updated `CapsemSandboxConfig`.""" + if not cfg.compose_file or not cfg.compose_file.strip(): + return _validate_direct_volumes(cfg) + from .containers import extract_capsem_compose_fields, parse_host_compose_yaml_file + + compose_path = Path(cfg.compose_file) + if not compose_path.is_file(): + raise FileNotFoundError(f"Compose file not found: {compose_path}") + + def _extract(meta: Mapping[str, Any] | None) -> dict[str, Any]: + parsed = parse_host_compose_yaml_file( + compose_path, allowed_host_env=cfg.allowed_host_env, sample_metadata=meta + ) + return extract_capsem_compose_fields( + parsed, + base_dir=compose_path.parent, + allowed_host_env=cfg.allowed_host_env, + allowed_host_paths=cfg.allowed_host_paths, + sample_metadata=meta, + ) + + overrides = _extract(sample_metadata) + base_overrides = _extract(None) if sample_metadata else {} + explicit = { + k + for k in cfg.model_fields_set + if not sample_metadata or getattr(cfg, k, None) != base_overrides.get(k) + } + data = { + **cfg.model_dump(exclude_unset=True), + **{k: v for k, v in overrides.items() if k not in explicit}, + } + return CapsemSandboxConfig(**data) + + +def coerce_config( + config: SandboxEnvironmentConfigType | Mapping[str, Any] | None, + *, + resolve_compose: bool = True, + sample_metadata: Mapping[str, Any] | None = None, +) -> CapsemSandboxConfig: + """Coerce an Inspect sandbox config argument into a `CapsemSandboxConfig`.""" + if config is None: + return CapsemSandboxConfig() + if isinstance(config, (CapsemSandboxConfig, dict)): + cfg = config if isinstance(config, CapsemSandboxConfig) else CapsemSandboxConfig(**config) + return ( + resolve_compose_file(cfg, sample_metadata=sample_metadata) + if (resolve_compose and cfg.compose_file) + else _validate_direct_volumes(cfg) + ) + if isinstance(config, str): + s = config.strip() + if s.lower().endswith((".yaml", ".yml")): + cfg = CapsemSandboxConfig(execution_mode="container", compose_file=s) + return ( + resolve_compose_file(cfg, sample_metadata=sample_metadata) + if resolve_compose + else cfg + ) + if _is_dockerfile_string(s): + raise ValueError( + f"Dockerfile / Containerfile builds ({s!r}) are not supported in Capsem " + "OCI-workload mode; specify a pre-built OCI image reference instead." + ) + return CapsemSandboxConfig(execution_mode="container", image=s) + if isinstance(config, ComposeConfig): + from .containers import extract_capsem_compose_fields + + overrides = extract_capsem_compose_fields( + config.model_dump(exclude_none=True), base_dir=None, sample_metadata=sample_metadata + ) + return CapsemSandboxConfig(**overrides) + raise TypeError(f"Unsupported Capsem sandbox config type: {type(config)!r}") diff --git a/integrations/inspect-ai/inspect_capsem/_controller.py b/integrations/inspect-ai/inspect_capsem/_controller.py index d19ae7b12..874718562 100644 --- a/integrations/inspect-ai/inspect_capsem/_controller.py +++ b/integrations/inspect-ai/inspect_capsem/_controller.py @@ -6,7 +6,7 @@ import contextlib import logging import os -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, Protocol, runtime_checkable @@ -16,6 +16,7 @@ ExecTimeoutError, HttpError, Hypervisor, + Registry, models, ) from capsem.execution import ( @@ -23,7 +24,7 @@ decode_exec_output, ) -from inspect_capsem._transfer import _staged_download, _staged_upload +from inspect_capsem._transfer import _OCI_STAGE_DIR, _staged_download, _staged_upload logger = logging.getLogger(__name__) @@ -52,8 +53,11 @@ async def start_vm( *, cpu_count: int, ram_gb: int, + image: str | None = None, + command: Sequence[str] | None = None, env: dict[str, str] | None = None, labels: Mapping[str, str] | None = None, + registry_ca_pem: str | None = None, ) -> str: ... async def stop_vm(self, vm_id: str) -> None: ... async def list_vms(self) -> list[models.SandboxInfo]: ... @@ -75,6 +79,13 @@ def is_root_user_spec(user: str | None) -> bool: return u.strip().lower() in ("", "root", "0") and g.strip().lower() in ("", "root", "0") +def _normalize_image_ref(image: str | None) -> str | None: + if not image or not image.strip(): + return None + ref = image.strip() + return ref if "://" in ref else f"docker://{ref}" + + def _managed_vm_prefix_slug() -> str: raw = os.environ.get("CAPSEM_VM_PREFIX", "").strip() if not raw: @@ -123,11 +134,14 @@ def __init__( *, url: str | None = None, token: str | None = None, + registry_ca_pem: str | None = None, ) -> None: if hypervisor is None: hypervisor = Hypervisor.connect(url, token, timeout=_SDK_CALL_TIMEOUT_SECS) self._hypervisor: Hypervisor = hypervisor + self._registry_ca_pem = registry_ca_pem self._sessions: dict[str, VM] = {} + self._oci_vms: set[str] = set() async def close(self) -> None: with contextlib.suppress(Exception): @@ -143,14 +157,24 @@ async def start_vm( *, cpu_count: int, ram_gb: int, + image: str | None = None, + command: Sequence[str] | None = None, env: dict[str, str] | None = None, labels: Mapping[str, str] | None = None, + registry_ca_pem: str | None = None, ) -> str: + norm_image = _normalize_image_ref(image) kwargs: dict[str, Any] = { "cpus": cpu_count, "memory": ram_gb, "labels": _managed_vm_labels(labels), } + if norm_image: + kwargs["image"] = norm_image + if eff_ca := (registry_ca_pem or self._registry_ca_pem): + kwargs["registry"] = Registry(ca_pem=eff_ca) + if command is not None: + kwargs["command"] = list(command) if env: kwargs["env"] = dict(env) try: @@ -160,6 +184,8 @@ async def start_vm( raise vm_id = str(session.id) self._sessions[vm_id] = session + if norm_image: + self._oci_vms.add(vm_id) return vm_id async def stop_vm(self, vm_id: str) -> None: @@ -185,8 +211,9 @@ def _session_for(self, vm_id: str) -> VM: async def exec_in_vm(self, vm_id: str, command: str, *, timeout: int = 120) -> CommandResult: session = self._session_for(vm_id) timeout = min(timeout, EXEC_TIMEOUT_CEILING_SECS) + target = models.ExecTarget.WORKLOAD if vm_id in self._oci_vms else models.ExecTarget.VM try: - res = await session.exec(command, timeout_secs=timeout, target=models.ExecTarget.VM) + res = await session.exec(command, timeout_secs=timeout, target=target) except ExecTimeoutError as exc: raise TimeoutError(f"Capsem exec timed out after {timeout}s: {exc}") from exc return CommandResult( @@ -198,10 +225,14 @@ async def exec_in_vm(self, vm_id: str, command: str, *, timeout: int = 120) -> C async def upload_to_vm(self, vm_id: str, guest_path: str, data: bytes) -> None: files = self._session_for(vm_id).files - await _staged_upload(self, files, vm_id, guest_path, data) + stage_dir = _OCI_STAGE_DIR if vm_id in self._oci_vms else None + await _staged_upload(self, files, vm_id, guest_path, data, stage_dir=stage_dir) async def download_from_vm( self, vm_id: str, guest_path: str, *, max_bytes: int | None = None ) -> bytes: files = self._session_for(vm_id).files - return await _staged_download(self, files, vm_id, guest_path, max_bytes=max_bytes) + stage_dir = _OCI_STAGE_DIR if vm_id in self._oci_vms else None + return await _staged_download( + self, files, vm_id, guest_path, max_bytes=max_bytes, stage_dir=stage_dir + ) diff --git a/integrations/inspect-ai/inspect_capsem/_exec.py b/integrations/inspect-ai/inspect_capsem/_exec.py index 8d6d64f10..2bb996999 100644 --- a/integrations/inspect-ai/inspect_capsem/_exec.py +++ b/integrations/inspect-ai/inspect_capsem/_exec.py @@ -8,7 +8,6 @@ import re import shlex import uuid -from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING from capsem.execution import EXEC_TIMEOUT_CEILING_SECS @@ -25,6 +24,32 @@ _TIMEOUT_SENTINEL = "__CAPSEM_INSPECT_EXEC_TIMED_OUT__" _EXEC_TIMEOUT_MARGIN_SECS = 10 _ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# When a root-owned OCI workload container inherits a non-`/root` `HOME` +# (for example from base-image `ENV`), reset `HOME=/root` for root execs. +_CONTAINER_HOME_FIX = ( + 'if [ "$(id -u)" = 0 ] && [ "${HOME:-/root}" != /root ] && [ -d /root ]; ' + "then export HOME=/root; fi; " +) +_CONTAINER_ROOT_CHECK = ( + '[ "$(id -u)" = 0 ] || { echo ' + '"capsem: cannot switch to root inside a non-root workload (no-new-privileges)" ' + ">&2; exit 126; }; " +) +_NONROOT_SWITCH_DENY = ( + 'elif [ "$(id -u)" != 0 ]; then echo ' + '"capsem: cannot switch user inside a non-root workload (no-new-privileges)" ' + ">&2; exit 126; " +) + + +def _wrap_target_command( + shell_cmd: str, *, is_container: bool = False, user: str | None = None +) -> str: + """Wrap a shell command for execution in the VM or OCI workload container.""" + if not is_container or not is_root_user_spec(user): + return shell_cmd + root_chk = _CONTAINER_ROOT_CHECK if user and user.strip() else "" + return f"bash -c {shlex.quote(f'{root_chk}{_CONTAINER_HOME_FIX}{shell_cmd}')}" def _truncate_utf8(text: str, max_bytes: int) -> tuple[str, bool]: @@ -62,45 +87,46 @@ def _format_exec_command( ) user_body_q = shlex.quote(f"{user_env_reset}{inner_body}") err_user_q = shlex.quote(f"capsem: unknown user {user}") + then_deny = f"then /bin/bash -c {user_body_q}; {_NONROOT_SWITCH_DENY}" if target_user.isdigit() and target_group.isdigit(): + setpriv_cmd = f"setpriv --reuid={target_user} --regid={target_group} --clear-groups" inner_body = ( - f"setpriv --reuid={target_user} --regid={target_group} " - f"--clear-groups /bin/bash -c {user_body_q}" + f'if [ "$(id -u):$(id -g)" = {target_user}:{target_group} ]; ' + f"{then_deny}else {setpriv_cmd} /bin/bash -c {user_body_q}; fi" ) elif target_user.isdigit() and not target_group: u_q = shlex.quote(target_user) + setpriv_cmd = f"setpriv --reuid={target_user} --regid=0 --clear-groups" inner_body = ( - f"if id -u {u_q} >/dev/null 2>&1; then " - f'su -m "$(id -un {u_q})" -s /bin/bash -c {user_body_q}; ' - f"else setpriv --reuid={target_user} --regid=0 " - f"--clear-groups /bin/bash -c {user_body_q}; fi" + f'if [ "$(id -u)" = {target_user} ]; {then_deny}' + f'elif id -u {u_q} >/dev/null 2>&1; then su -m "$(id -un {u_q})" -s /bin/bash -c {user_body_q}; ' + f"else {setpriv_cmd} /bin/bash -c {user_body_q}; fi" ) elif not target_group: su_user = shlex.quote(target_user) inner_body = ( - f"if id -u {su_user} >/dev/null 2>&1; then " - f"su -m {su_user} -s /bin/bash -c {user_body_q}; " - f"else echo {err_user_q} >&2; exit 1; fi" + f"_uid=$(id -u {su_user} 2>/dev/null) || {{ echo {err_user_q} >&2; exit 1; }}; " + f'if [ "$(id -u)" = "$_uid" ]; {then_deny}' + f"else su -m {su_user} -s /bin/bash -c {user_body_q}; fi" ) else: u_q, g_q = shlex.quote(target_user), shlex.quote(target_group) + err_grp_q = shlex.quote(f"capsem: unknown group {target_group}") uid_step = ( f"_uid={target_user}; " if target_user.isdigit() else f"_uid=$(id -u {u_q} 2>/dev/null) || {{ echo {err_user_q} >&2; exit 1; }}; " ) - err_grp_q = shlex.quote(f"capsem: unknown group {target_group}") gid_step = ( f"_gid={target_group}; " if target_group.isdigit() - else ( - f"_gid=$(getent group {g_q} 2>/dev/null | cut -d: -f3); " - f'[ -n "$_gid" ] || {{ echo {err_grp_q} >&2; exit 1; }}; ' - ) + else f"_gid=$(getent group {g_q} 2>/dev/null | cut -d: -f3); " + f'[ -n "$_gid" ] || {{ echo {err_grp_q} >&2; exit 1; }}; ' ) + setpriv_cmd = 'setpriv --reuid="$_uid" --regid="$_gid" --clear-groups' inner_body = ( - f'{uid_step}{gid_step}setpriv --reuid="$_uid" --regid="$_gid" ' - f"--clear-groups /bin/bash -c {user_body_q}" + f'{uid_step}{gid_step}if [ "$(id -u):$(id -g)" = "$_uid:$_gid" ]; ' + f"{then_deny}else {setpriv_cmd} /bin/bash -c {user_body_q}; fi" ) if timeout is None: return inner_body, EXEC_TIMEOUT_CEILING_SECS @@ -117,7 +143,8 @@ def _format_exec_command( async def _prepare_exec_expr( - stage_bytes: Callable[[bytes, str], Awaitable[None]], + controller: CapsemController, + vm_id: str, quoted_cmd: str, input_data: str | bytes | None, temp_files: list[str], @@ -125,7 +152,7 @@ async def _prepare_exec_expr( if len(quoted_cmd) > 65536: script_guest = f"/tmp/.capsem_cmd_{uuid.uuid4().hex[:10]}.sh" temp_files.append(script_guest) - await stage_bytes(quoted_cmd.encode("utf-8"), script_guest) + await controller.upload_to_vm(vm_id, script_guest, quoted_cmd.encode("utf-8")) exec_expr = f"bash {shlex.quote(script_guest)}" else: exec_expr = quoted_cmd @@ -138,7 +165,7 @@ async def _prepare_exec_expr( else: stdin_guest = f"/tmp/.capsem_stdin_{uuid.uuid4().hex[:10]}.dat" temp_files.append(stdin_guest) - await stage_bytes(input_bytes, stdin_guest) + await controller.upload_to_vm(vm_id, stdin_guest, input_bytes) exec_expr = f"{exec_expr} < {shlex.quote(stdin_guest)}" return exec_expr @@ -154,8 +181,10 @@ async def exec_in_sandbox( env_vars: dict[str, str] | None = None, user: str | None = None, timeout: int | None = None, + *, + is_container: bool = False, ) -> ExecResult[str]: - """Execute command in Capsem sandbox VM, enforcing timeouts and limits.""" + """Execute command in Capsem sandbox VM or OCI workload container.""" if not cmd: return ExecResult(success=True, returncode=0, stdout="", stderr="") @@ -165,15 +194,11 @@ async def exec_in_sandbox( temp_files_to_clean: list[str] = [] cleaned_inline = False controller_timeout: int = EXEC_TIMEOUT_CEILING_SECS + effective_user = user or default_user try: - - async def stage_bytes(data: bytes, path: str) -> None: - await controller.upload_to_vm(vm_id, path, data) - exec_expr = await _prepare_exec_expr( - stage_bytes, quoted_cmd, input_data, temp_files_to_clean + controller, vm_id, quoted_cmd, input_data, temp_files_to_clean ) - effective_user = user or default_user timed_script, controller_timeout = _format_exec_command( exec_expr, effective_cwd=effective_cwd, @@ -184,7 +209,8 @@ async def stage_bytes(data: bytes, path: str) -> None: if temp_files_to_clean: rm_targets = " ".join(shlex.quote(p) for p in temp_files_to_clean) timed_script = f"( {timed_script} ); __ec=$?; rm -f {rm_targets}; exit $__ec" - res = await controller.exec_in_vm(vm_id, timed_script, timeout=controller_timeout) + wrapped = _wrap_target_command(timed_script, is_container=is_container, user=effective_user) + res = await controller.exec_in_vm(vm_id, wrapped, timeout=controller_timeout) cleaned_inline = True except TimeoutError as exc: effective_timeout = timeout if timeout is not None else controller_timeout @@ -198,7 +224,9 @@ async def stage_bytes(data: bytes, path: str) -> None: stderr_str = res.stderr if timeout is not None and _TIMEOUT_SENTINEL in stderr_str: raise TimeoutError(f"Command timed out after {timeout}s") - if res.exit_code == 126 and "permission denied" in stderr_str.lower(): + if res.exit_code == 126 and any( + s in stderr_str.lower() for s in ("permission denied", "no-new-privileges") + ): raise PermissionError(stderr_str.strip()) limit_bytes = SandboxEnvironmentLimits.MAX_EXEC_OUTPUT_SIZE diff --git a/integrations/inspect-ai/inspect_capsem/_files.py b/integrations/inspect-ai/inspect_capsem/_files.py index f07f29962..a05ba98a2 100644 --- a/integrations/inspect-ai/inspect_capsem/_files.py +++ b/integrations/inspect-ai/inspect_capsem/_files.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import posixpath import shlex from typing import TYPE_CHECKING @@ -11,6 +12,21 @@ if TYPE_CHECKING: from inspect_capsem._controller import CapsemController +logger = logging.getLogger(__name__) + + +def _chown_to_container_user_snippet(target_q: str, default_user: str | None) -> str: + """Build shell snippet that chowns `target_q` to `default_user` or `$1` when non-root.""" + fallback_q = shlex.quote((default_user or "").strip()) + return ( + f'__u={fallback_q}; [ -z "$__u" ] && __u="$1"; ' + 'if [ -n "$__u" ] && [ "$__u" != "0" ] && [ "$__u" != "root" ] ' + '&& [ "$__u" != "0:0" ] && [ "$__u" != "root:root" ]; then ' + 'case "$__u" in *:*) chown "$__u" ' + f'{target_q} ;; *) chown "$__u:" {target_q} 2>/dev/null || chown "$__u" {target_q} ;; ' + "esac; fi" + ) + def _resolve_guest_path(path: str, working_dir: str) -> str: """Resolve `path` against `working_dir` if relative; return normalized path.""" @@ -50,7 +66,7 @@ async def read_guest_file( *, text: bool = True, ) -> str | bytes: - """Read `file` from guest VM, enforcing size limits and permissions.""" + """Read `file` from guest VM or OCI container, enforcing size limits and permissions.""" resolved = _resolve_guest_path(file, working_dir) limit_bytes = SandboxEnvironmentLimits.MAX_READ_FILE_SIZE res_q = shlex.quote(resolved) @@ -98,8 +114,11 @@ async def write_guest_file( working_dir: str, file: str, contents: str | bytes, + *, + is_container: bool = False, + default_user: str | None = None, ) -> None: - """Write `contents` to `file` in guest VM, checking permissions.""" + """Write `contents` to `file` in guest VM or OCI workload container.""" resolved = _resolve_guest_path(file, working_dir) data = contents.encode("utf-8") if isinstance(contents, str) else contents res_q = shlex.quote(resolved) @@ -118,3 +137,18 @@ async def write_guest_file( raise PermissionError(f"Permission denied: '{file}'") await controller.upload_to_vm(vm_id, resolved, data) + if is_container: + chown_snip = _chown_to_container_user_snippet(res_q, default_user) + chown_res = await controller.exec_in_vm( + vm_id, + f'set -- "$(stat -c %u:%g /proc/1 2>/dev/null)"; {chown_snip}', + timeout=30, + ) + if chown_res.exit_code != 0: + logger.warning( + "Failed to chown %s in container (VM %s, exit %s): %s", + resolved, + vm_id, + chown_res.exit_code, + (chown_res.stderr or chown_res.stdout).strip(), + ) diff --git a/integrations/inspect-ai/inspect_capsem/_lifecycle.py b/integrations/inspect-ai/inspect_capsem/_lifecycle.py index b18e9d500..8171e3e16 100644 --- a/integrations/inspect-ai/inspect_capsem/_lifecycle.py +++ b/integrations/inspect-ai/inspect_capsem/_lifecycle.py @@ -5,6 +5,7 @@ import asyncio import contextlib import logging +import shlex import uuid from collections.abc import Callable from typing import TYPE_CHECKING @@ -57,6 +58,14 @@ def _abandon_process_owned_vms(task_name: str | None = None) -> list[str]: return surviving_ids +def _oci_create_command(cfg: CapsemSandboxConfig) -> tuple[str, ...] | None: + if cfg.command is None: + return None + if isinstance(cfg.command, str): + return tuple(shlex.split(cfg.command)) if cfg.command.strip() else None + return tuple(cfg.command) + + async def _init_sample_vm( controller: CapsemController, cfg: CapsemSandboxConfig, task_name: str ) -> str: @@ -65,10 +74,14 @@ async def _init_sample_vm( sample_labels = {_NONCE_LABEL: nonce} if task_name: sample_labels[_TASK_LABEL] = task_name[:255] + oci_image = cfg.image if cfg.execution_mode == "container" else None + oci_cmd = _oci_create_command(cfg) if cfg.execution_mode == "container" else None start_task = asyncio.ensure_future( controller.start_vm( cpu_count=cfg.cpu_count, ram_gb=cfg.ram_gb, + image=oci_image, + command=oci_cmd, env=dict(cfg.environment) if cfg.environment else None, labels=sample_labels, ) diff --git a/integrations/inspect-ai/inspect_capsem/_transfer.py b/integrations/inspect-ai/inspect_capsem/_transfer.py index 5a3a97a4e..9b1a56e25 100644 --- a/integrations/inspect-ai/inspect_capsem/_transfer.py +++ b/integrations/inspect-ai/inspect_capsem/_transfer.py @@ -23,6 +23,7 @@ async def write(self, path: str, data: bytes, /) -> object: ... _XFER_PART_BYTES = MAX_REQUEST_BODY_BYTES - 2 * 1024 * 1024 _XFER_STAGE_DIR = "/root" +_OCI_STAGE_DIR = "/workspace" # Direct Files API eligibility check: the service's path filter silently strips # characters outside `[A-Za-z0-9._-/]` instead of returning 400, so only # relative paths that the service stores verbatim take the direct Files API fast @@ -59,6 +60,8 @@ async def _staged_upload( vm_id: str, guest_path: str, data: bytes, + *, + stage_dir: str | None = None, ) -> None: dest_q = shlex.quote(guest_path) parent_q = shlex.quote(posixpath.dirname(guest_path) or "/") @@ -71,9 +74,9 @@ async def _staged_upload( what="Empty upload", ) return - stage_dir = _XFER_STAGE_DIR + effective_stage_dir = stage_dir if stage_dir is not None else _XFER_STAGE_DIR part_bytes = _XFER_PART_BYTES - rel = _rel_to_stage_dir(guest_path, stage_dir) + rel = _rel_to_stage_dir(guest_path, effective_stage_dir) if rel is not None and len(data) <= part_bytes: try: await files.write(rel, data) @@ -82,7 +85,7 @@ async def _staged_upload( if exc.status not in (400, 403, 413): raise stage = f".capsem-xfer-{uuid.uuid4().hex[:12]}" - stage_q = shlex.quote(posixpath.join(stage_dir, stage)) + stage_q = shlex.quote(posixpath.join(effective_stage_dir, stage)) cleaned_inline = False try: for index, offset in enumerate(range(0, len(data), part_bytes)): @@ -113,10 +116,11 @@ async def _staged_download( guest_path: str, *, max_bytes: int | None = None, + stage_dir: str | None = None, ) -> bytes: - stage_dir = _XFER_STAGE_DIR + effective_stage_dir = stage_dir if stage_dir is not None else _XFER_STAGE_DIR part_bytes = _XFER_PART_BYTES - rel = _rel_to_stage_dir(guest_path, stage_dir) + rel = _rel_to_stage_dir(guest_path, effective_stage_dir) if rel is not None: try: direct = bytes(await files.read(rel)) @@ -125,7 +129,7 @@ async def _staged_download( if exc.status not in (400, 403, 413): raise stage = f".capsem-xfer-{uuid.uuid4().hex[:12]}" - stage_q = shlex.quote(posixpath.join(stage_dir, stage)) + stage_q = shlex.quote(posixpath.join(effective_stage_dir, stage)) guest_q = shlex.quote(guest_path) split_cmd = ( f"head -c {max_bytes + 1} -- {guest_q} | split -b {part_bytes} -d -a 6 - part." diff --git a/integrations/inspect-ai/inspect_capsem/config.py b/integrations/inspect-ai/inspect_capsem/config.py index 995c82143..61888ecc1 100644 --- a/integrations/inspect-ai/inspect_capsem/config.py +++ b/integrations/inspect-ai/inspect_capsem/config.py @@ -2,21 +2,37 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import logging +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal from pydantic import BaseModel, Field, model_validator if TYPE_CHECKING: - from inspect_ai.util import SandboxEnvironmentConfigType + from .containers import ContainerSpec + +logger = logging.getLogger(__name__) +_REJECTED_DOCKERFILE_KEYS = frozenset( + {"dockerfile", "build", "build_context", "build_args", "build_target", "dockerfile_inline"} +) + + +def _json_hashable(value: Any) -> Any: + if isinstance(value, dict): + return tuple(sorted((str(k), _json_hashable(v)) for k, v in value.items())) + if isinstance(value, (list, tuple)): + return tuple(_json_hashable(item) for item in value) + return value class CapsemSandboxConfig(BaseModel, frozen=True, extra="forbid"): - """Configuration for `CapsemSandboxEnvironment` (VM-level execution).""" + """Configuration for `CapsemSandboxEnvironment` (VM and container execution).""" @model_validator(mode="before") @classmethod def _reject_unsupported_knobs(cls, data: Any) -> Any: - if isinstance(data, dict): + if isinstance(data, Mapping): if data.get("template") is not None: msg = ( "CapsemSandboxConfig does not support template selection yet; " @@ -35,27 +51,91 @@ def _reject_unsupported_knobs(cls, data: Any) -> Any: "a host VirtioFS mount" ) raise NotImplementedError(msg) + if data.get("ports") not in (None, (), []): + raise ValueError( + "Config field 'ports' is not supported in Capsem OCI-workload mode; " + "Capsem isolates workloads inside a micro-VM without host port forwarding." + ) + for bad_key in _REJECTED_DOCKERFILE_KEYS: + if data.get(bad_key) is not None: + raise ValueError( + f"Config field {bad_key!r} is not supported in Capsem OCI-workload mode; " + "specify a pre-built 'image' reference instead." + ) + out = dict(data) + out.pop("ports", None) + out.pop("expose", None) + if out.pop("init", None): + logger.warning( + "Ignoring 'init=True' on CapsemSandboxConfig: Capsem OCI-workload mode " + "supervises the container process directly." + ) + if out.get("allowed_host_env"): + from .containers import validate_host_env_patterns + + validate_host_env_patterns(out["allowed_host_env"], source="allowed_host_env") + if "execution_mode" not in out: + for key in ("compose_file", "image"): + if isinstance(out.get(key), str) and out[key].strip(): + out["execution_mode"] = "container" + break + return out return data + @model_validator(mode="after") + def _validate_container_mode_source(self) -> CapsemSandboxConfig: + if ( + self.execution_mode == "container" + and not (self.image and self.image.strip()) + and not (self.compose_file and self.compose_file.strip()) + ): + raise ValueError( + "execution_mode='container' requires an explicit OCI image reference " + "(set 'image' or 'compose_file')." + ) + return self + + execution_mode: Literal["vm", "container"] = "vm" + image: str | None = None cpu_count: int = Field(default=4) ram_gb: int = Field(default=8) - working_dir: str = Field(default="/workspace") + working_dir: str | None = Field(default=None) + compose_file: str | None = None environment: dict[str, str] = Field(default_factory=dict) + command: tuple[str, ...] | str | None = None + volumes: tuple[str, ...] = () + healthcheck: dict[str, Any] | None = None + mem_limit: str | None = None user: str | None = Field(default=None) + allowed_host_env: tuple[str, ...] = () + allowed_host_paths: tuple[str, ...] = () def __hash__(self) -> int: - return hash(self.model_dump_json()) - - -def coerce_config( - config: SandboxEnvironmentConfigType | dict[str, Any] | str | None, -) -> CapsemSandboxConfig: - """Coerce an Inspect sandbox config into a `CapsemSandboxConfig`.""" - if config is None: - return CapsemSandboxConfig() - if isinstance(config, CapsemSandboxConfig): - return config - if isinstance(config, dict): - return CapsemSandboxConfig.model_validate(config) - msg = f"Unsupported Capsem sandbox config type: {type(config)!r}" - raise TypeError(msg) + return hash(tuple(_json_hashable(getattr(self, k)) for k in type(self).model_fields)) + + def to_container_spec(self) -> ContainerSpec: + """Convert this sandbox config into an Inspect-free `ContainerSpec`.""" + from .containers import ContainerSpec, resolve_effective_allowed_host_paths + + if not self.image or not self.image.strip(): + raise ValueError( + "execution_mode='container' requires an explicit OCI image reference " + "(set 'image' or a Compose service 'image')." + ) + eff_paths = list(resolve_effective_allowed_host_paths(self.allowed_host_paths)) + if self.compose_file and self.compose_file.strip(): + compose_dir = str(Path(self.compose_file).expanduser().resolve().parent) + if compose_dir not in eff_paths: + eff_paths.append(compose_dir) + return ContainerSpec( + image=self.image, + working_dir=self.working_dir or "/workspace", + working_dir_explicit=self.working_dir is not None, + environment=dict(self.environment), + command=self.command, + volumes=self.volumes, + healthcheck=dict(self.healthcheck) if self.healthcheck is not None else None, + mem_limit=self.mem_limit, + user=self.user, + allowed_host_paths=tuple(eff_paths), + ) diff --git a/integrations/inspect-ai/inspect_capsem/containers/__init__.py b/integrations/inspect-ai/inspect_capsem/containers/__init__.py new file mode 100644 index 000000000..fe9d865a6 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/__init__.py @@ -0,0 +1,29 @@ +"""Inspect-free OCI container and Compose support for `inspect-capsem`.""" + +from __future__ import annotations + +from .compose import extract_compose_fields, parse_compose_yaml_file, parse_host_compose_yaml_file +from .compose_fields import ( + extract_capsem_compose_fields, + normalize_volumes, + resolve_effective_allowed_host_paths, +) +from .compose_inputs import validate_host_env_patterns +from .controller import ContainerCommandResult, ContainerController +from .runtime import prepare_oci_workload_container, resolve_container_working_dir +from .spec import ContainerSpec + +__all__ = [ + "ContainerCommandResult", + "ContainerController", + "ContainerSpec", + "extract_capsem_compose_fields", + "extract_compose_fields", + "normalize_volumes", + "parse_compose_yaml_file", + "parse_host_compose_yaml_file", + "prepare_oci_workload_container", + "resolve_container_working_dir", + "resolve_effective_allowed_host_paths", + "validate_host_env_patterns", +] diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose.py b/integrations/inspect-ai/inspect_capsem/containers/compose.py index cd87b5c85..fbfe5b18c 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose.py @@ -7,12 +7,18 @@ from __future__ import annotations -from pathlib import PurePath +from collections.abc import Mapping, Sequence +from pathlib import Path, PurePath from typing import Any import yaml -from .compose_inputs import EMPTY_INPUTS, InterpolationBudget +from .compose_inputs import ( + DEFAULT_COMPOSE_LIMITS, + EMPTY_INPUTS, + InterpolationBudget, + build_host_compose_inputs, +) from .compose_inputs import ComposeInputs as ComposeInputs from .compose_inputs import ComposeLimits as ComposeLimits from .compose_interpolation import _interpolate_compose_str, _load_dotenv @@ -137,12 +143,18 @@ def _bounded_yaml(text: str, limits: ComposeLimits) -> Any: def parse_compose_yaml( - text: str, *, limits: ComposeLimits, inputs: ComposeInputs = EMPTY_INPUTS, dotenv: str = "" + text: str, + *, + limits: ComposeLimits = DEFAULT_COMPOSE_LIMITS, + inputs: ComposeInputs = EMPTY_INPUTS, + dotenv: str = "", ) -> dict[str, Any]: if len(text.encode("utf-8")) + len(dotenv.encode("utf-8")) > limits.maximum_bytes: raise ValueError("Compose input byte limit exceeded") budget = InterpolationBudget(limits) dotenv_variables = _load_dotenv(dotenv, inputs.environment, budget) + budget.blocked_environment = inputs.blocked_environment - set(dotenv_variables) + budget.warned_blocked = inputs.warned_blocked raw = _bounded_yaml(text, limits) if not isinstance(raw, dict): return {} @@ -153,7 +165,10 @@ def parse_compose_yaml( def parse_compose_yaml_file( - compose_path: PurePath, *, inputs: ComposeInputs, limits: ComposeLimits + compose_path: PurePath, + *, + inputs: ComposeInputs, + limits: ComposeLimits = DEFAULT_COMPOSE_LIMITS, ) -> dict[str, Any]: """Parse already-supplied file text; never opens or probes its pathname.""" text = inputs.files.get(inputs.resolve(compose_path)) @@ -161,3 +176,20 @@ def parse_compose_yaml_file( raise PermissionError("Compose file text was not supplied by the evaluator") dotenv = inputs.files.get(inputs.resolve(compose_path.parent / ".env"), "") return parse_compose_yaml(text, inputs=inputs, dotenv=dotenv, limits=limits) + + +def parse_host_compose_yaml_file( + compose_path: Path, + *, + allowed_host_env: Sequence[str] = (), + sample_metadata: Mapping[str, Any] | None = None, + limits: ComposeLimits = DEFAULT_COMPOSE_LIMITS, +) -> dict[str, Any]: + """Parse `compose_path` from host disk with bounded YAML, `.env`, and allowlisted host env.""" + inputs = build_host_compose_inputs( + compose_path, + allowed_host_env=allowed_host_env, + sample_metadata=sample_metadata, + limits=limits, + ) + return parse_compose_yaml_file(compose_path, inputs=inputs, limits=limits) diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py b/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py new file mode 100644 index 000000000..c196d81d1 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py @@ -0,0 +1,237 @@ +"""Compose service field normalizers and host-path containment helpers.""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Collection, Mapping, Sequence +from pathlib import Path, PurePosixPath +from typing import Any + +from .compose_inputs import build_host_compose_inputs +from .compose_service import extract_compose_fields +from .compose_values import ( + _NAMED_VOL_RE, + _combine_entrypoint_and_command, + _looks_like_host_bind_source, + _resolve_declared_volumes, + parse_cpus_to_cpu_count, + parse_memory_to_ram_gb, +) +from .compose_values import ( + _normalize_environment as _normalize_compose_environment, +) +from .compose_values import ( + _normalize_volumes as _normalize_compose_volumes, +) + +logger = logging.getLogger(__name__) +CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR = "CAPSEM_INSPECT_ALLOWED_HOST_PATHS" +_UNSUPPORTED_KEYS = ( + "cap_add cap_drop devices security_opt sysctls pid ipc uts cgroup cgroup_parent userns_mode" +) +_BUILD_ERR = ( + "not supported in Capsem OCI-workload mode; specify a pre-built 'image' reference instead." +) +_REJECTED_SERVICE_KEYS: dict[str, str] = { + **{ + k: f"Compose {k!r} is not supported in Capsem OCI-workload mode." + for k in _UNSUPPORTED_KEYS.split() + }, + "build": f"Compose 'build' / Dockerfile builds are {_BUILD_ERR}", + "dockerfile": f"Compose 'dockerfile' builds are {_BUILD_ERR}", + "privileged": ( + "Compose 'privileged' is not supported in Capsem OCI-workload mode; " + "Capsem isolates workloads via micro-VM hardware virtualization." + ), +} + + +def _realpath(raw: str | Path) -> Path: + return Path(os.path.realpath(Path(raw).expanduser())) + + +def _is_under_root(target: Path, root: Path) -> bool: + return target == root or target.is_relative_to(root) + + +def resolve_effective_allowed_host_paths( + task_allowed_host_paths: Sequence[str] = (), +) -> tuple[str, ...]: + """Resolve effective host-path allowlist from operator env narrowed by task config.""" + raw_op = os.environ.get(CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR, "") + op_roots = tuple( + s.strip() for chunk in raw_op.split(",") for s in chunk.split(os.pathsep) if s.strip() + ) + if not op_roots: + return () + task_roots = tuple(r.strip() for r in task_allowed_host_paths if r and r.strip()) + if not task_roots: + return op_roots + res_op, res_task = [_realpath(r) for r in op_roots], [_realpath(r) for r in task_roots] + narrowed = [str(tr) for tr in res_task if any(_is_under_root(tr, o) for o in res_op)] + for opr in res_op: + if any(_is_under_root(opr, tr) for tr in res_task) and str(opr) not in narrowed: + narrowed.append(str(opr)) + return tuple(narrowed) + + +def _is_host_path_allowed( + candidate: Path, allowed_host_paths: Sequence[str] = (), *, base_dir: Path | None = None +) -> bool: + """Return True if `candidate`'s `realpath` stays inside `base_dir` or allowed roots.""" + resolved = _realpath(candidate) + if base_dir is not None and _is_under_root(resolved, _realpath(base_dir)): + return True + return any(_is_under_root(resolved, _realpath(r)) for r in allowed_host_paths if r) + + +def _resolve_bind_source( + src: str, base_dir: Path | None, allowed_host_paths: Sequence[str] = () +) -> str: + expanded = Path(src).expanduser() + target = expanded + if base_dir is not None and not expanded.is_absolute() and not src.startswith("~"): + cand = base_dir / expanded + if src.startswith(".") or "/" in src or cand.exists() or cand.is_symlink(): + target = cand + resolved = target.resolve() + if expanded.name == "docker.sock" or resolved.name == "docker.sock": + raise ValueError( + "Mounting the host Docker socket ('docker.sock') into a sandbox is forbidden." + ) + eff_roots = resolve_effective_allowed_host_paths(allowed_host_paths) + if not _is_host_path_allowed(resolved, eff_roots, base_dir=base_dir): + raise ValueError( + f"Host bind mount source {src!r} (resolved to {str(resolved)!r}) is outside the " + f"Compose directory and not allowlisted via {CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR} " + "/ allowed_host_paths." + ) + return str(resolved) + + +def _normalize_environment( + raw_env: Any, + base_dir: Path | None = None, + *, + allowed_host_env: Sequence[str] = (), + sample_metadata: Mapping[str, Any] | None = None, +) -> dict[str, str]: + inputs = build_host_compose_inputs( + base_dir=base_dir, + allowed_host_env=allowed_host_env, + sample_metadata=sample_metadata, + include_dotenv=True, + ) + return _normalize_compose_environment(raw_env, inputs) + + +def normalize_volumes( + raw_vols: Any, + base_dir: Path | None, + allowed_host_paths: Sequence[str] = (), + *, + declared_volumes: Collection[str] = (), +) -> tuple[str, ...]: + """Validate and resolve Compose or config bind-mount and declared named volumes.""" + if not isinstance(raw_vols, (list, tuple)) or not raw_vols: + return () + specs = _normalize_compose_volumes( + [dict(v) if isinstance(v, Mapping) else v for v in raw_vols], base_dir + ) + out: list[str] = [] + for spec in specs: + parts = spec.split(":") + if len(parts) >= 2 and PurePosixPath(parts[1].strip()).name == "docker.sock": + raise ValueError( + "Mounting the host Docker socket ('docker.sock') into a sandbox is forbidden." + ) + if len(parts) >= 2 and _looks_like_host_bind_source(parts[0]): + parts[0] = _resolve_bind_source(parts[0], base_dir, allowed_host_paths) + out.append(":".join(parts)) + elif ( + len(parts) >= 2 + and parts[0] in declared_volumes + and _NAMED_VOL_RE.match(parts[0]) + and parts[1].strip().startswith("/") + ): + out.append(":".join(parts)) + else: + raise ValueError( + f"Named or non-bind Compose volume {spec!r} is not supported in Capsem " + "OCI-workload mode unless declared in top-level 'volumes:'; " + "use a relative or allowlisted host(allowed_host_paths) bind mount." + ) + return tuple(out) + + +def extract_capsem_compose_fields( + parsed: Mapping[str, Any], + base_dir: Path | None = None, + *, + allowed_host_env: Sequence[str] = (), + allowed_host_paths: Sequence[str] = (), + sample_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Extract and validate Capsem container overrides from a parsed Compose mapping.""" + declared_vols = _resolve_declared_volumes(parsed.get("volumes")) + services, svc, svc_name, parsed_for_extract = parsed.get("services"), {}, "default", parsed + if isinstance(services, Mapping) and len(services) == 1: + raw_name, raw_svc = next(iter(services.items())) + svc_name = str(raw_name) + if isinstance(raw_svc, Mapping): + svc = raw_svc + for key, msg in _REJECTED_SERVICE_KEYS.items(): + if svc.get(key) not in (None, False, [], (), {}): + raise ValueError(msg) + drop = ("build", "dockerfile", "cpus", "platform") + svc_clean = {k: v for k, v in svc.items() if k not in drop} + parsed_for_extract = {**parsed, "services": {svc_name: svc_clean}} + inputs = build_host_compose_inputs( + base_dir=base_dir, + allowed_host_env=allowed_host_env, + sample_metadata=sample_metadata, + include_dotenv=True, + ) + out = extract_compose_fields(parsed_for_extract, base_dir=base_dir, inputs=inputs) + net_mode = out.pop("network_mode", None) + explicit_net = svc.get("network_mode") + if isinstance(explicit_net, str) and (cleaned_mode := explicit_net.strip()): + if cleaned_mode not in ("bridge", "default"): + raise ValueError( + f"Compose network_mode {cleaned_mode!r} is not supported by inspect-capsem: " + "per-VM network mode / air-gapped egress is not yet wired in the Capsem 0.7 " + "ProvisionRequest contract." + ) + elif net_mode == "none": + raise ValueError( + "Compose 'internal: true' networks are not supported by inspect-capsem: " + "per-VM air-gapped egress is not yet wired in the Capsem 0.7 ProvisionRequest contract." + ) + if out.pop("ports", None): + raise ValueError("Compose 'ports' is not supported in Capsem OCI-workload mode.") + out.pop("expose", None) + if out.pop("init", None): + logger.warning( + "Ignoring Compose 'init: true' in service %r: Capsem OCI-workload mode supervises " + "the container process directly.", + svc_name, + ) + cmd = _combine_entrypoint_and_command(out.pop("entrypoint", None), out.pop("command", None)) + if cmd is not None: + out["command"] = cmd + if "mem_limit" in out: + out["ram_gb"] = parse_memory_to_ram_gb(out["mem_limit"]) + d: Any = svc + for k in ("deploy", "resources", "limits"): + d = d.get(k) if isinstance(d, Mapping) else None + cpus_raw = svc.get("cpus") + if cpus_raw is None and isinstance(d, Mapping): + cpus_raw = d.get("cpus") + if cpus_raw is not None: + out["cpu_count"] = parse_cpus_to_cpu_count(cpus_raw) + if raw_vols := out.pop("volumes", None): + out["volumes"] = normalize_volumes( + raw_vols, base_dir, allowed_host_paths, declared_volumes=declared_vols + ) + return out diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_inputs.py b/integrations/inspect-ai/inspect_capsem/containers/compose_inputs.py index 56d1c3e20..4c41160aa 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose_inputs.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_inputs.py @@ -2,20 +2,95 @@ from __future__ import annotations +import fnmatch +import logging +import os import posixpath -from collections.abc import Mapping +import re +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field -from pathlib import PurePath +from pathlib import Path, PurePath from types import MappingProxyType +from typing import Any from .dockerfile_metadata import _extract_dockerfile_metadata +logger = logging.getLogger(__name__) +CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR = "CAPSEM_INSPECT_ALLOWED_HOST_ENV" +_ENV_PAT_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_*?]*$") +_SAMPLE_METADATA_PREFIX = "SAMPLE_METADATA_" +AllowedEnvSpec = Sequence[str] | tuple[tuple[str, ...], tuple[str, ...]] + + +def validate_host_env_patterns(patterns: Sequence[str], *, source: str) -> tuple[str, ...]: + """Validate and strip host-env allowlist patterns.""" + cleaned: list[str] = [] + for raw in patterns: + if not (pat := raw.strip()): + continue + if not _ENV_PAT_RE.match(pat): + raise ValueError( + f"Wildcard-only or invalid pattern {pat!r} is not allowed in {source}; " + "specify explicit variable names or a literal prefix such as 'MY_APP_*'." + ) + cleaned.append(pat) + return tuple(cleaned) + + +_validate_host_env_patterns = validate_host_env_patterns + + +def resolve_effective_allowed_host_env( + task_allowed_host_env: Sequence[str] = (), +) -> tuple[str, ...] | tuple[tuple[str, ...], tuple[str, ...]]: + """Return effective host-env patterns from operator env narrowed by task config.""" + task_pats = validate_host_env_patterns(task_allowed_host_env, source="allowed_host_env") + op_pats = validate_host_env_patterns( + os.environ.get(CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR, "").split(","), + source=CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR, + ) + return ((op_pats, task_pats) if task_pats else op_pats) if op_pats else () + + +def _is_host_env_allowed(name: str, allowed: AllowedEnvSpec) -> bool: + """Return True if `name` matches the effective host-env allowlist.""" + if not allowed: + return False + if isinstance(allowed[0], tuple): + return all(any(fnmatch.fnmatchcase(name, p) for p in grp) for grp in allowed) + return any(fnmatch.fnmatchcase(name, str(pat)) for pat in allowed) + + +def _warn_blocked_host_env(name: str, warned_blocked: set[str] | None) -> None: + if warned_blocked is None or name not in warned_blocked: + if warned_blocked is not None: + warned_blocked.add(name) + logger.warning( + "Ignoring non-allowlisted host environment variable %r in Compose " + "(set %s or allowed_host_env to pass it through).", + name, + CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR, + ) + + +def build_sample_metadata_env(sample_metadata: Mapping[str, Any] | None) -> dict[str, str]: + """Convert sample `metadata` into `SAMPLE_METADATA_` strings.""" + if not sample_metadata: + return {} + return { + f"{_SAMPLE_METADATA_PREFIX}{str(k).replace(' ', '_').upper()}": str(v) + for k, v in sample_metadata.items() + if v is not None + } + @dataclass(frozen=True) class ComposeInputs: environment: Mapping[str, str] = field(default_factory=dict) files: Mapping[str, str] = field(default_factory=dict) directories: frozenset[str] = frozenset() + blocked_environment: frozenset[str] = frozenset() + warned_blocked: set[str] = field(default_factory=set, compare=False, repr=False, hash=False) def __post_init__(self) -> None: object.__setattr__(self, "environment", MappingProxyType(dict(self.environment))) @@ -27,6 +102,7 @@ def __post_init__(self) -> None: object.__setattr__( self, "directories", frozenset(self.resolve(path) for path in self.directories) ) + object.__setattr__(self, "blocked_environment", frozenset(self.blocked_environment)) @staticmethod def resolve(path: str | PurePath) -> str: @@ -40,6 +116,13 @@ def exists(self, path: str | PurePath) -> bool: key = self.resolve(path) return key in self.files or key in self.directories + def lookup_env(self, key: str) -> str: + if key in self.environment: + return self.environment[key] + if key in self.blocked_environment: + _warn_blocked_host_env(key, self.warned_blocked) + return "" + def dockerfile_defaults(self, path: str | PurePath) -> dict[str, str]: text = self.files.get(self.resolve(path)) if text is None: @@ -69,13 +152,27 @@ def __post_init__(self) -> None: raise ValueError("Compose limits must be positive integers") +DEFAULT_COMPOSE_LIMITS = ComposeLimits(maximum_bytes=262144, maximum_nodes=4096, maximum_depth=32) + + class InterpolationBudget: """Bound intermediate expanded fragments as well as the output tree.""" - def __init__(self, limits: ComposeLimits): + def __init__( + self, + limits: ComposeLimits, + *, + blocked_environment: frozenset[str] = frozenset(), + warned_blocked: set[str] | None = None, + ): self.limits = limits self.remaining_bytes = limits.maximum_bytes self.remaining_nodes = limits.maximum_nodes + self.blocked_environment = blocked_environment + self.warned_blocked = warned_blocked + + def warn_blocked(self, name: str) -> None: + _warn_blocked_host_env(name, self.warned_blocked) def depth(self, depth: int) -> None: if depth > self.limits.maximum_depth: @@ -101,3 +198,47 @@ def __init__(self, budget: InterpolationBudget): def append(self, value: str) -> None: self.budget.consume(value) super().append(value) + + +def build_host_compose_inputs( + compose_path: Path | None = None, + *, + base_dir: Path | None = None, + allowed_host_env: Sequence[str] = (), + sample_metadata: Mapping[str, Any] | None = None, + include_dotenv: bool = False, + limits: ComposeLimits = DEFAULT_COMPOSE_LIMITS, +) -> ComposeInputs: + """Build `ComposeInputs` from operator-allowlisted host env, `.env`, and sample metadata.""" + eff_dir = compose_path.parent if compose_path is not None else base_dir + files: dict[str, str] = {} + if compose_path is not None and compose_path.is_file(): + files[str(compose_path)] = compose_path.read_text(encoding="utf-8") + dotenv_text = "" + if eff_dir is not None and (dotenv_path := eff_dir / ".env").is_file(): + dotenv_text = dotenv_path.read_text(encoding="utf-8") + files[str(dotenv_path)] = dotenv_text + eff_allowed = resolve_effective_allowed_host_env(allowed_host_env) + allowed_env: dict[str, str] = {} + blocked_keys: set[str] = set() + for k, v in os.environ.items(): + if k.startswith(_SAMPLE_METADATA_PREFIX): + continue + if _is_host_env_allowed(k, eff_allowed): + allowed_env[k] = v + else: + blocked_keys.add(k) + sample_env = build_sample_metadata_env(sample_metadata) + dotenv_vars: dict[str, str] = {} + if include_dotenv and dotenv_text: + from .compose_interpolation import _load_dotenv + + dotenv_vars = _load_dotenv( + dotenv_text, {**allowed_env, **sample_env}, InterpolationBudget(limits) + ) + blocked_keys.difference_update(dotenv_vars) + return ComposeInputs( + environment={**dotenv_vars, **allowed_env, **sample_env}, + files=files, + blocked_environment=frozenset(blocked_keys), + ) diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_interpolation.py b/integrations/inspect-ai/inspect_capsem/containers/compose_interpolation.py index 9d353f8a1..57a7cedfe 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose_interpolation.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_interpolation.py @@ -5,7 +5,11 @@ import re from collections.abc import Mapping -from .compose_inputs import Fragments, InterpolationBudget +from .compose_inputs import ( + CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR, + Fragments, + InterpolationBudget, +) _BRACED_VAR_HEAD_RE = re.compile( r"^([A-Za-z_][A-Za-z0-9_]*)(?:(:-|-|:\?|\?|:\+|\+)(.*))?$", re.DOTALL @@ -80,7 +84,7 @@ def _load_dotenv( else: val = _INLINE_COMMENT_RE.sub("", val).strip() val = _interpolate_compose_str(val, {**env_vars, **environment}, budget) - if key: + if key and not key.startswith("SAMPLE_METADATA_"): env_vars[key] = val return env_vars @@ -95,6 +99,14 @@ def _eval_braced_compose_var( name = m.group(1) op = m.group(2) raw_arg = m.group(3) or "" + if name not in env_lookup and name in budget.blocked_environment: + if op in (":?", "?"): + raise ValueError( + f"Required Compose variable {name!r} is set in the host environment " + f"but is not allowlisted via {CAPSEM_INSPECT_ALLOWED_HOST_ENV_VAR} / " + "allowed_host_env" + ) + budget.warn_blocked(name) if op is None: return env_lookup.get(name, "") if op == ":-": @@ -181,7 +193,10 @@ def _interpolate_compose_str( raise ValueError(msg) var_match = _BARE_VAR_RE.match(text, i + 1) if var_match is not None: - out.append(env_lookup.get(var_match.group(1), "")) + var_name = var_match.group(1) + if var_name not in env_lookup and var_name in budget.blocked_environment: + budget.warn_blocked(var_name) + out.append(env_lookup.get(var_name, "")) i = var_match.end() continue out.append("$") diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_service.py b/integrations/inspect-ai/inspect_capsem/containers/compose_service.py index 2b9b157c3..2ef3ecef4 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose_service.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_service.py @@ -100,8 +100,8 @@ def _extract_service_fields( ) overrides: dict[str, Any] = {"execution_mode": "container"} svc_image = _field(selected_svc, "image") - if svc_image: - overrides["image"] = str(svc_image) + if svc_image is not None and str(svc_image).strip(): + overrides["image"] = str(svc_image).strip() svc_build = _field(selected_svc, "build") if svc_build: if isinstance(svc_build, str): @@ -136,8 +136,8 @@ def _extract_service_fields( if build_target: overrides["build_target"] = str(build_target) svc_workdir = _field(selected_svc, "working_dir") - if svc_workdir: - overrides["working_dir"] = str(svc_workdir) + if svc_workdir is not None and str(svc_workdir).strip(): + overrides["working_dir"] = str(svc_workdir).strip() svc_env = _normalize_environment(_field(selected_svc, "environment"), inputs) if svc_env: overrides["environment"] = svc_env @@ -174,8 +174,8 @@ def _extract_service_fields( if net_mode: overrides["network_mode"] = net_mode svc_user = _field(selected_svc, "user") - if svc_user: - overrides["user"] = str(svc_user) + if svc_user is not None and str(svc_user).strip(): + overrides["user"] = str(svc_user).strip() if "dockerfile" in overrides: for k, v in inputs.dockerfile_defaults(overrides["dockerfile"]).items(): overrides.setdefault(k, v) diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_values.py b/integrations/inspect-ai/inspect_capsem/containers/compose_values.py index 6f406eceb..e705e27d8 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose_values.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_values.py @@ -3,7 +3,11 @@ from __future__ import annotations import logging +import math import posixpath +import re +import shlex +from collections.abc import Mapping from pathlib import Path from typing import Any @@ -12,6 +16,13 @@ from .compose_inputs import EMPTY_INPUTS, ComposeInputs logger = logging.getLogger(__name__) +_NAMED_VOL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +_MEM_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([bkmg]i?b?)?\s*$", re.IGNORECASE) +_MEM_FACTORS = { + u: 1024**p + for p, us in enumerate((" b", "k kb kib", "m mb mib", "g gb gib")) + for u in us.split(" ") +} def _field(obj: Any, key: str, default: Any = None) -> Any: @@ -29,13 +40,34 @@ def _is_bind_mount_source(src: str) -> bool: return src.startswith((".", "/", "~")) or "/" in src or "\\" in src +def _resolve_declared_volumes(raw_top_volumes: Any) -> frozenset[str]: + if raw_top_volumes is None: + return frozenset() + if not isinstance(raw_top_volumes, Mapping): + raise ValueError("Top-level Compose 'volumes' must be a mapping.") + declared: set[str] = set() + for vol_name, vol_cfg in raw_top_volumes.items(): + name_str = str(vol_name).strip() + if not _NAMED_VOL_RE.match(name_str): + raise ValueError(f"Invalid top-level Compose volume name {name_str!r}.") + if isinstance(vol_cfg, Mapping): + driver = str(vol_cfg.get("driver") or "local").strip() + if vol_cfg.get("external") or driver != "local" or vol_cfg.get("driver_opts"): + raise ValueError( + f"Top-level Compose volume {name_str!r} uses unsupported external or " + "non-local driver options in Capsem OCI-workload mode." + ) + declared.add(name_str) + return frozenset(declared) + + def _normalize_environment(svc_env: Any, inputs: ComposeInputs = EMPTY_INPUTS) -> dict[str, str]: if isinstance(svc_env, dict): env_map: dict[str, str] = {} for k, v in svc_env.items(): key = str(k) if v is None: - env_map[key] = inputs.environment.get(key, "") + env_map[key] = inputs.lookup_env(key) elif isinstance(v, bool): env_map[key] = "true" if v else "false" else: @@ -49,7 +81,7 @@ def _normalize_environment(svc_env: Any, inputs: ComposeInputs = EMPTY_INPUTS) - k, _, v = text.partition("=") env_map[k] = v elif text: - env_map[text] = inputs.environment.get(text, "") + env_map[text] = inputs.lookup_env(text) return env_map return {} @@ -88,7 +120,12 @@ def _normalize_volumes( item, ) ro = bool(item.get("read_only", False)) - spec = f"{src}:{target}{':ro' if ro else ''}" + src_str = str(src) + if vtype == "volume" and _is_bind_mount_source(src_str): + raise ValueError( + f"Named Compose volume source {src_str!r} must be a volume name, not a path" + ) + spec = f"{src_str}:{target}{':ro' if ro else ''}" is_bind = vtype == "bind" else: spec = str(item) @@ -96,7 +133,7 @@ def _normalize_volumes( is_bind = _is_bind_mount_source(src_head) if is_bind and base_dir is not None and ":" in spec: src_part, _, rest = spec.partition(":") - if src_part and not posixpath.isabs(src_part): + if src_part and not posixpath.isabs(src_part) and not src_part.startswith("~"): candidate = base_dir / src_part if src_part.startswith(".") or "/" in src_part or inputs.exists(candidate): spec = f"{inputs.resolve(candidate)}:{rest}" @@ -142,3 +179,51 @@ def _normalize_healthcheck(svc_hc: Any) -> dict[str, Any] | None: if val is not None: hc[key] = list(val) if isinstance(val, tuple) else val return hc or None + + +def parse_memory_to_ram_gb(raw_mem: Any) -> int: + """Convert Compose memory limit (`512m`, `2g`, integer bytes) up to integer GiB (`>=1`).""" + if isinstance(raw_mem, (int, float)) and not isinstance(raw_mem, bool): + if raw_mem <= 0: + raise ValueError(f"Invalid Compose memory limit: {raw_mem!r}") + return max(1, math.ceil(float(raw_mem) / (1024**3))) + m = _MEM_RE.match(str(raw_mem).strip()) + if not m: + raise ValueError(f"Invalid Compose memory limit: {raw_mem!r}") + amount, unit = float(m.group(1)), (m.group(2) or "").lower() + if amount <= 0 or unit not in _MEM_FACTORS: + raise ValueError(f"Invalid Compose memory limit: {raw_mem!r}") + return max(1, math.ceil((amount * _MEM_FACTORS[unit]) / (1024**3))) + + +def parse_cpus_to_cpu_count(raw_cpus: Any) -> int: + """Convert Compose `cpus` (`"1.5"`, `2`) up to integer vCPU count (`>=1`).""" + try: + val = float(raw_cpus) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid Compose cpus limit: {raw_cpus!r}") from exc + if val <= 0 or math.isnan(val) or math.isinf(val): + raise ValueError(f"Invalid Compose cpus limit: {raw_cpus!r}") + return max(1, math.ceil(val)) + + +def _looks_like_host_bind_source(src: str) -> bool: + return ( + src in (".", "..") + or src.startswith(("/", "./", "../", "~")) + or "/" in src + or "\\" in src + or (len(src) >= 2 and src[1] == ":" and src[0].isalpha()) + ) + + +def _combine_entrypoint_and_command( + entrypoint: tuple[str, ...] | str | None, command: tuple[str, ...] | str | None +) -> tuple[str, ...] | str | None: + if entrypoint is None: + return command + ep_parts = tuple(shlex.split(entrypoint)) if isinstance(entrypoint, str) else entrypoint + if not ep_parts or command is None: + return ep_parts if ep_parts else command + cmd_parts = tuple(shlex.split(command)) if isinstance(command, str) else command + return (*ep_parts, *cmd_parts) diff --git a/integrations/inspect-ai/inspect_capsem/containers/controller.py b/integrations/inspect-ai/inspect_capsem/containers/controller.py new file mode 100644 index 000000000..fb3771fff --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/controller.py @@ -0,0 +1,27 @@ +"""Container runtime boundary types (`ContainerCommandResult`, `ContainerController`).""" + +from __future__ import annotations + +from typing import Protocol + + +class ContainerCommandResult(Protocol): + """Structural protocol for command execution results inside a Capsem VM or container.""" + + @property + def exit_code(self) -> int: ... + @property + def stdout(self) -> str: ... + @property + def stderr(self) -> str: ... + @property + def truncated(self) -> bool: ... + + +class ContainerController(Protocol): + """Protocol abstracting Capsem host/gateway operations for container setup.""" + + async def exec_in_vm( + self, vm_id: str, command: str, *, timeout: int = 120 + ) -> ContainerCommandResult: ... + async def upload_to_vm(self, vm_id: str, guest_path: str, data: bytes) -> None: ... diff --git a/integrations/inspect-ai/inspect_capsem/containers/runtime.py b/integrations/inspect-ai/inspect_capsem/containers/runtime.py new file mode 100644 index 000000000..8475bfd5f --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/runtime.py @@ -0,0 +1,217 @@ +"""OCI workload container staging, healthcheck polling, and working_dir probing.""" + +from __future__ import annotations + +import asyncio +import gzip +import io +import os +import re +import shlex +import tarfile +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +from .compose_fields import ( + _NAMED_VOL_RE, + CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR, + _is_host_path_allowed, + _looks_like_host_bind_source, +) +from .controller import ContainerController +from .spec import ContainerSpec + +_MAX_BIND_MOUNT_BYTES = 256 * 1024 * 1024 +_DURATION_RE = re.compile(r"(\d+(?:\.\d+)?)(ms|s|m|h)") +_DURATION_MULTIPLIERS = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0} + + +def _reset_tarinfo_ownership(ti: tarfile.TarInfo) -> tarfile.TarInfo: + ti.uid = ti.gid = 0 + ti.uname = ti.gname = "" + return ti + + +def _pack_directory_tar_gz(src_dir: Path) -> bytes: + total = 0 + buf = io.BytesIO() + with ( + gzip.GzipFile(filename="", mode="wb", fileobj=buf, compresslevel=1, mtime=0) as gz, + tarfile.open(fileobj=gz, mode="w") as tf, + ): + for root, dirs, files in os.walk(src_dir, followlinks=False): + dirs.sort() + files.sort() + root_path = Path(root) + for name in (*dirs, *files): + full = root_path / name + st = full.lstat() + if (st.st_mode & 0o170000) == 0o100000: + total += st.st_size + if total > _MAX_BIND_MOUNT_BYTES: + raise ValueError( + f"Host bind mount directory {src_dir} exceeds maximum size " + f"({_MAX_BIND_MOUNT_BYTES} bytes)." + ) + rel = full.relative_to(src_dir).as_posix() + tf.add(full, arcname=rel, recursive=False, filter=_reset_tarinfo_ownership) + return buf.getvalue() + + +async def _stage_oci_bind_volumes( + controller: ContainerController, + vm_id: str, + volumes: Sequence[str], + *, + allowed_host_paths: Sequence[str] = (), +) -> None: + """Upload host bind-mount files/directories or stage declared named volumes in the container.""" + for idx, vol in enumerate(volumes): + parts = vol.split(":") + if len(parts) < 2: + raise ValueError( + f"Named or invalid Compose volume {vol!r} is not supported in Capsem " + "OCI-workload mode." + ) + guest_dst = parts[1].strip() + if not guest_dst.startswith("/"): + raise ValueError(f"Container volume target path must be absolute, got {guest_dst!r}") + if not _looks_like_host_bind_source(parts[0]): + if not _NAMED_VOL_RE.match(parts[0]): + raise ValueError( + f"Named or invalid Compose volume {vol!r} is not supported in Capsem " + "OCI-workload mode." + ) + mode_cmd = ( + "chmod a+rx" if (len(parts) >= 3 and "ro" in parts[2].split(",")) else "chmod a+rwx" + ) + q_dst = shlex.quote(guest_dst) + res = await controller.exec_in_vm( + vm_id, f"mkdir -p {q_dst} && ({mode_cmd} {q_dst} 2>/dev/null || true)" + ) + if res.exit_code != 0: + raise RuntimeError( + f"Failed staging named volume {parts[0]!r} at {guest_dst}: " + f"{res.stderr or res.stdout}" + ) + continue + host_src = Path(parts[0]).expanduser() + if not _is_host_path_allowed(host_src, allowed_host_paths): + raise ValueError( + f"Host bind mount source {parts[0]!r} (resolved to " + f"{str(host_src.resolve())!r}) is not in {CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR} " + "/ allowed_host_paths." + ) + resolved = host_src.resolve() + if not resolved.exists(): + raise FileNotFoundError(f"Host bind mount source not found: {resolved}") + if resolved.is_dir(): + tmp_tar = f"/tmp/.capsem_bind_{idx}.tar.gz" + await controller.upload_to_vm(vm_id, tmp_tar, _pack_directory_tar_gz(resolved)) + q_dst, q_tar = shlex.quote(guest_dst), shlex.quote(tmp_tar) + res = await controller.exec_in_vm( + vm_id, + f"mkdir -p {q_dst} && tar -xzf {q_tar} -C {q_dst}; " + f"__ec=$?; rm -f {q_tar}; exit $__ec", + ) + if res.exit_code != 0: + raise RuntimeError( + f"Failed extracting bind mount into {guest_dst}: {res.stderr or res.stdout}" + ) + else: + st = resolved.stat() + if st.st_size > _MAX_BIND_MOUNT_BYTES: + raise ValueError( + f"Host bind mount file {resolved} exceeds maximum size " + f"({_MAX_BIND_MOUNT_BYTES} bytes)." + ) + data = resolved.read_bytes() + if (parent := str(Path(guest_dst).parent)) and parent != "/": + mk_res = await controller.exec_in_vm(vm_id, f"mkdir -p {shlex.quote(parent)}") + if mk_res.exit_code != 0: + raise RuntimeError( + f"Failed creating parent directory {parent}: " + f"{mk_res.stderr or mk_res.stdout}" + ) + await controller.upload_to_vm(vm_id, guest_dst, data) + mode_oct = f"{st.st_mode & 0o777:o}" + ch_res = await controller.exec_in_vm( + vm_id, f"chmod {mode_oct} {shlex.quote(guest_dst)}" + ) + if ch_res.exit_code != 0: + raise RuntimeError( + f"Failed setting mode {mode_oct} on {guest_dst}: " + f"{ch_res.stderr or ch_res.stdout}" + ) + + +def _parse_compose_duration(val: Any, default_secs: float) -> float: + if isinstance(val, (int, float)): + return max(0.1, float(val)) + if isinstance(val, str) and (matches := list(_DURATION_RE.finditer(val.strip()))): + return max(0.1, sum(float(m.group(1)) * _DURATION_MULTIPLIERS[m.group(2)] for m in matches)) + return default_secs + + +async def _wait_for_oci_healthcheck( + controller: ContainerController, vm_id: str, healthcheck: dict[str, Any] +) -> None: + test = healthcheck.get("test") + if isinstance(test, (list, tuple)) and len(test) >= 2: + kind = str(test[0]).upper() + hc_cmd = ( + str(test[1]) if kind == "CMD-SHELL" else " ".join(shlex.quote(str(x)) for x in test[1:]) + ) + elif isinstance(test, str) and test.strip(): + hc_cmd = test.strip() + else: + return + + interval = _parse_compose_duration(healthcheck.get("interval"), 2.0) + timeout = max(1, int(_parse_compose_duration(healthcheck.get("timeout"), 10.0))) + start_period = _parse_compose_duration(healthcheck.get("start_period"), 0.0) + retries_raw = healthcheck.get("retries") + retries = ( + max(1, int(retries_raw)) + if isinstance(retries_raw, (int, str)) and str(retries_raw).strip().isdigit() + else 3 + ) + total_attempts = retries + max(0, int(start_period / max(interval, 0.5))) + + probe_cmd, last_err = f"sh -c {shlex.quote(hc_cmd)}", "" + for attempt in range(total_attempts): + res = await controller.exec_in_vm(vm_id, probe_cmd, timeout=timeout) + if res.exit_code == 0: + return + last_err = (res.stderr or res.stdout).strip() + if attempt + 1 < total_attempts: + await asyncio.sleep(min(interval, 2.0)) + raise RuntimeError( + f"Container in VM {vm_id} failed healthcheck ({hc_cmd!r}) " + f"after {total_attempts} attempts: {last_err}" + ) + + +async def prepare_oci_workload_container( + controller: ContainerController, vm_id: str, spec: ContainerSpec +) -> None: + """Prepare an OCI workload container created via `Hypervisor.create(image=...)`.""" + if spec.volumes: + await _stage_oci_bind_volumes( + controller, vm_id, spec.volumes, allowed_host_paths=spec.allowed_host_paths + ) + if spec.working_dir_explicit and spec.working_dir and spec.working_dir != "/": + q_wd = shlex.quote(spec.working_dir) + await controller.exec_in_vm( + vm_id, f"mkdir -p {q_wd} && (chmod a+rx {q_wd} 2>/dev/null || true)" + ) + if spec.healthcheck: + await _wait_for_oci_healthcheck(controller, vm_id, spec.healthcheck) + + +async def resolve_container_working_dir(controller: ContainerController, vm_id: str) -> str: + """Probe the active container's initial `WORKDIR` via `pwd` (falling back to `/`).""" + res = await controller.exec_in_vm(vm_id, "pwd", timeout=15) + pwd_out = res.stdout.strip().splitlines() + return pwd_out[-1] if (res.exit_code == 0 and pwd_out and pwd_out[-1].startswith("/")) else "/" diff --git a/integrations/inspect-ai/inspect_capsem/containers/spec.py b/integrations/inspect-ai/inspect_capsem/containers/spec.py new file mode 100644 index 000000000..e87c169a8 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/spec.py @@ -0,0 +1,22 @@ +"""Inspect-free OCI container specification value object.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class ContainerSpec: + """Self-contained OCI container launch specification.""" + + image: str | None = None + working_dir: str = "/workspace" + working_dir_explicit: bool = False + environment: dict[str, str] = field(default_factory=dict) + command: tuple[str, ...] | str | None = None + volumes: tuple[str, ...] = () + healthcheck: dict[str, Any] | None = None + mem_limit: str | None = None + user: str | None = None + allowed_host_paths: tuple[str, ...] = () diff --git a/integrations/inspect-ai/inspect_capsem/sandbox.py b/integrations/inspect-ai/inspect_capsem/sandbox.py index 67de5e5ba..3880af1e6 100644 --- a/integrations/inspect-ai/inspect_capsem/sandbox.py +++ b/integrations/inspect-ai/inspect_capsem/sandbox.py @@ -15,12 +15,13 @@ from inspect_capsem import _cleanup as _cl from inspect_capsem import _lifecycle as _lc +from inspect_capsem._compose import coerce_config from inspect_capsem._controller import CapsemController, SdkCapsemController from inspect_capsem._exec import exec_in_sandbox from inspect_capsem._files import read_guest_file, write_guest_file from inspect_capsem._lifecycle import sweep_leftover_vms from inspect_capsem._tools import bake_sandbox_tools_into_controller -from inspect_capsem.config import CapsemSandboxConfig, coerce_config +from inspect_capsem.config import CapsemSandboxConfig logger = logging.getLogger(__name__) @@ -52,6 +53,7 @@ def __init__( controller: CapsemController | None = None, *, working_dir: str = "/workspace", + execution_mode: Literal["vm", "container"] = "vm", owns_controller: bool | None = None, task_name: str | None = None, user: str | None = None, @@ -59,25 +61,31 @@ def __init__( super().__init__() self._vm_id = vm_id self._working_dir = working_dir + self._execution_mode: Literal["vm", "container"] = execution_mode self._cleaned_up = False self._controller = SdkCapsemController() if controller is None else controller self._owns_controller = (controller is None) if owns_controller is None else owns_controller self._task_name = task_name self._user = user - self._instance_id = f"{vm_id}:{uuid.uuid4().hex[:6]}" + self._instance_id = f"{vm_id}:{execution_mode}:{uuid.uuid4().hex[:6]}" CapsemSandboxEnvironment._active_environments[self._instance_id] = self @property def vm_id(self) -> str: return self._vm_id + @property + def execution_mode(self) -> Literal["vm", "container"]: + return self._execution_mode + @classmethod def config_files(cls) -> list[str]: - return [] + compose = [f"{p}.{e}" for p in ("compose", "docker-compose") for e in ("yaml", "yml")] + return [*compose, "Dockerfile", "Containerfile"] @classmethod def is_docker_compatible(cls) -> bool: - return False + return True @classmethod def default_concurrency(cls) -> int | None: @@ -85,7 +93,7 @@ def default_concurrency(cls) -> int | None: @classmethod def config_deserialize(cls, config: dict[str, Any]) -> CapsemSandboxConfig: - return coerce_config(config) + return coerce_config(config, resolve_compose=False) @classmethod async def task_init(cls, task_name: str, config: _CfgType | str | None) -> None: @@ -120,17 +128,30 @@ async def task_cleanup( async def sample_init( cls, task_name: str, config: _CfgType | str | None, metadata: dict[str, str] ) -> dict[str, SandboxEnvironment]: - del metadata - cfg = coerce_config(config) + cfg = coerce_config(config, sample_metadata=metadata) controller = SdkCapsemController() vm_id = "" try: vm_id = await _lc._init_sample_vm(controller, cfg, task_name) - mkdir_cmd = f"mkdir -p {shlex.quote(cfg.working_dir)}" - await controller.exec_in_vm(vm_id, mkdir_cmd, timeout=30) + working_dir = cfg.working_dir or "/workspace" + if cfg.execution_mode == "container": + from inspect_capsem import containers as _c + + spec = cfg.to_container_spec() + await _c.prepare_oci_workload_container(controller, vm_id, spec) + if not spec.working_dir_explicit: + working_dir = await _c.resolve_container_working_dir(controller, vm_id) + else: + mkdir_cmd = f"mkdir -p {shlex.quote(working_dir)}" + await controller.exec_in_vm(vm_id, mkdir_cmd, timeout=30) await bake_sandbox_tools_into_controller(controller, vm_id) env = cls( - vm_id, controller, working_dir=cfg.working_dir, task_name=task_name, user=cfg.user + vm_id, + controller, + working_dir=working_dir, + execution_mode=cfg.execution_mode, + task_name=task_name, + user=cfg.user, ) env._owns_controller = True return {"default": env} @@ -187,10 +208,24 @@ async def exec( ) -> ExecResult[str]: del timeout_retry, concurrency args = (self._working_dir, self._user, input, cwd, env, user, timeout) - return await exec_in_sandbox(self._controller, self._vm_id, cmd, *args) + return await exec_in_sandbox( + self._controller, + self._vm_id, + cmd, + *args, + is_container=self._execution_mode == "container", + ) async def write_file(self, file: str, contents: str | bytes) -> None: - await write_guest_file(self._controller, self._vm_id, self._working_dir, file, contents) + await write_guest_file( + self._controller, + self._vm_id, + self._working_dir, + file, + contents, + is_container=self._execution_mode == "container", + default_user=self._user, + ) @overload async def read_file(self, file: str, text: Literal[True] = True) -> str: ... diff --git a/integrations/inspect-ai/pyproject.toml b/integrations/inspect-ai/pyproject.toml index 46ce6d9e3..904773454 100644 --- a/integrations/inspect-ai/pyproject.toml +++ b/integrations/inspect-ai/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "capsem>=0.7.0", "inspect-ai>=0.3.263", "pydantic>=2.0.0", + "pyyaml>=6.0", ] [project.urls] diff --git a/integrations/inspect-ai/tests/containers/__init__.py b/integrations/inspect-ai/tests/containers/__init__.py new file mode 100644 index 000000000..a3e04e947 --- /dev/null +++ b/integrations/inspect-ai/tests/containers/__init__.py @@ -0,0 +1 @@ +"""Container unit tests package.""" diff --git a/integrations/inspect-ai/tests/containers/test_compose.py b/integrations/inspect-ai/tests/containers/test_compose.py new file mode 100644 index 000000000..fde64b7c5 --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_compose.py @@ -0,0 +1,186 @@ +"""Compose field extraction, resource mapping, and fail-fast rejection tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import inspect_capsem.containers +import inspect_capsem.containers.compose +import inspect_capsem.containers.compose_fields as compose_fields_mod +import pytest +import yaml +from inspect_capsem._compose import coerce_config + + +def test_compose_field_extraction_and_operator_allowlists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert ( + inspect_capsem.containers.extract_compose_fields + is inspect_capsem.containers.compose.extract_compose_fields + ) + assert ( + inspect_capsem.containers.parse_compose_yaml_file + is inspect_capsem.containers.compose.parse_compose_yaml_file + ) + extract = compose_fields_mod.extract_capsem_compose_fields + for bad in ({"services": {}}, {"execution_mode": "vm"}): + with pytest.raises(ValueError, match="non-empty 'services' mapping"): + extract(bad) + with pytest.raises(ValueError, match="Multi-service Compose files are not supported"): + extract({"services": {"a": {"image": "a"}, "b": {"image": "b"}}}, base_dir=tmp_path) + + ctx, outside = tmp_path / "ctx", tmp_path / "outside" + ctx.mkdir() + outside.mkdir() + monkeypatch.setenv("INHERITED_VAR", "from_os") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", "INHERITED_*") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(outside)) + rich = extract( + { + "services": { + "app": { + "image": "ubuntu:24.04", + "environment": ["K=V", "INHERITED_VAR"], + "command": "echo hi", + "entrypoint": "/ep", + "volumes": ["./ctx:/mnt:ro", f"{outside}:/abs"], + "expose": [9000], + "init": True, + "deploy": {"resources": {"limits": {"memory": "256m", "cpus": "1.5"}}}, + "network_mode": "bridge", + "user": "nobody", + "healthcheck": {"test": ("CMD", "true"), "retries": 5}, + } + } + }, + base_dir=tmp_path, + ) + assert rich["environment"] == {"K": "V", "INHERITED_VAR": "from_os"} + assert rich["command"] == ("/ep", "echo", "hi") + assert rich["volumes"] == (f"{ctx.resolve()}:/mnt:ro", f"{outside.resolve()}:/abs") + assert (rich["mem_limit"], rich["ram_gb"], rich["cpu_count"]) == ("256m", 1, 2) + assert (rich["user"], rich["healthcheck"]) == ( + "nobody", + {"test": ["CMD", "true"], "retries": 5}, + ) + + +def test_compose_networks_and_resource_limits() -> None: + nets = {"isolated": {"internal": True}, "ext": {"internal": False}} + for internal_compose in ( + {"networks": nets, "services": {"a": {"image": "alpine:3.20", "networks": ["isolated"]}}}, + { + "networks": nets, + "services": { + "a": {"image": "a", "networks": {"isolated": {}, "local": {"internal": True}}} + }, + }, + {"networks": {"default": {"internal": True}}, "services": {"a": {"image": "alpine:3.20"}}}, + ): + with pytest.raises(ValueError, match="internal: true"): + compose_fields_mod.extract_capsem_compose_fields(internal_compose) + online = { + "networks": nets, + "services": {"o": {"image": "a:1", "networks": ["isolated", "ext"]}}, + } + assert compose_fields_mod.extract_capsem_compose_fields(online)["image"] == "a:1" + assert compose_fields_mod.parse_memory_to_ram_gb("512m") == 1 + assert compose_fields_mod.parse_memory_to_ram_gb("2500MiB") == 3 + assert compose_fields_mod.parse_memory_to_ram_gb(4 * 1024**3) == 4 + with pytest.raises(ValueError, match="Invalid Compose memory limit"): + compose_fields_mod.parse_memory_to_ram_gb("invalid") + assert compose_fields_mod.parse_cpus_to_cpu_count("0.5") == 1 + assert compose_fields_mod.parse_cpus_to_cpu_count(2.25) == 3 + with pytest.raises(ValueError, match="Invalid Compose cpus limit"): + compose_fields_mod.parse_cpus_to_cpu_count("0") + + +def test_compose_long_form_env_and_volumes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + bind_dir = tmp_path / "src" + bind_dir.mkdir() + monkeypatch.setenv("INHERITED_DICT_KEY", "inherited_val") + monkeypatch.setenv("BLOCKED_DICT_KEY", "secret_val") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", "INHERITED_DICT_KEY") + monkeypatch.delenv("UNSET_DICT_KEY", raising=False) + long_compose = { + "services": { + "web": { + "image": "nginx:alpine", + "environment": { + "INHERITED_DICT_KEY": None, + "BLOCKED_DICT_KEY": None, + "UNSET_DICT_KEY": None, + "BOOL_T": True, + "BOOL_F": False, + }, + "volumes": [ + {"type": "bind", "source": "./src", "target": "/app/src", "read_only": True} + ], + } + } + } + with caplog.at_level(logging.WARNING): + ext_long = compose_fields_mod.extract_capsem_compose_fields(long_compose, base_dir=tmp_path) + assert "Ignoring non-allowlisted host environment variable 'BLOCKED_DICT_KEY'" in caplog.text + assert ext_long["environment"] == { + "INHERITED_DICT_KEY": "inherited_val", + "BLOCKED_DICT_KEY": "", + "UNSET_DICT_KEY": "", + "BOOL_T": "true", + "BOOL_F": "false", + } + assert ext_long["volumes"] == (f"{bind_dir.resolve()}:/app/src:ro",) + for bad_vols, match in ( + ([{"type": "tmpfs", "target": "/tmp"}], "Unsupported Compose volume type 'tmpfs'"), + (["named-vol:/var/lib/data"], "Named or non-bind Compose volume"), + (["/var/lib/data"], "Named or non-bind Compose volume"), + ([{"type": "bind", "source": "./src"}], "requires both 'source' and 'target'"), + (["/var/run/docker.sock:/var/run/docker.sock"], "docker.sock"), + (["./docker.sock:/mnt/custom.sock"], "docker.sock"), + (["./src:/var/run/docker.sock"], "docker.sock"), + ): + with pytest.raises(ValueError, match=match): + compose_fields_mod.normalize_volumes(bad_vols, tmp_path) + + +def test_unsupported_compose_features_rejected_at_coerce_config(tmp_path: Path) -> None: + compose_path = tmp_path / "compose.yaml" + lkeys = ("cap_add", "cap_drop", "devices", "security_opt", "ports", "env_file", "depends_on") + nmodes = ("none", "host", "service:db", "container:db") + cases: tuple[tuple[dict[str, object], str], ...] = ( + ({"build": "."}, "build"), + ({"build": {"context": ".", "dockerfile": "Dockerfile"}}, "build"), + ({"build": {"dockerfile_inline": "FROM alpine\n"}}, "build"), + ({"dockerfile": "Dockerfile"}, "dockerfile"), + ({"privileged": True}, "privileged"), + *(({k: ["x"]}, k) for k in lkeys), + *(({k: "host"}, k) for k in ("pid", "ipc", "uts", "cgroup")), + ({"sysctls": {"net.ipv4.ip_forward": "1"}}, "sysctls"), + *(({"network_mode": m}, "network_mode") for m in nmodes), + ) + for override, expected_match in cases: + compose_path.write_text( + yaml.safe_dump({"services": {"app": {"image": "alpine:3.20", **override}}}) + ) + with pytest.raises(ValueError, match=expected_match): + coerce_config(str(compose_path)) + + +def test_unknown_benign_compose_keys_warn_and_x_extensions_ignored( + caplog: pytest.LogCaptureFixture, +) -> None: + svc = { + "image": "alpine:3.20", + "shm_size": "64m", + "tmpfs": ["/run"], + "platform": "linux/amd64", + "x-inspect": {"k": "v"}, + } + with caplog.at_level(logging.WARNING): + compose_fields_mod.extract_capsem_compose_fields({"services": {"app": svc}}) + assert "shm_size, tmpfs" in caplog.text + assert "platform" not in caplog.text and "x-inspect" not in caplog.text diff --git a/integrations/inspect-ai/tests/containers/test_compose_host_env.py b/integrations/inspect-ai/tests/containers/test_compose_host_env.py new file mode 100644 index 000000000..f94e1ea89 --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_compose_host_env.py @@ -0,0 +1,187 @@ +"""Compose `.env` loading, `SAMPLE_METADATA_*`, and operator host-env allowlist tests.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import inspect_capsem._compose as compose_mod +import inspect_capsem.containers.compose as c_compose_mod +import inspect_capsem.sandbox as sb_mod +import pytest +from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment +from inspect_capsem.containers.compose_inputs import ( + DEFAULT_COMPOSE_LIMITS, + ComposeLimits, + InterpolationBudget, +) +from inspect_capsem.containers.compose_interpolation import _load_dotenv + +from ..helpers import Scripted + + +def test_compose_variable_interpolation_and_dotenv( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Verify `.env` + operator `CAPSEM_INSPECT_ALLOWED_HOST_ENV` + `${VAR:-/:/?/+}` + `$$`.""" + (tmp_path / ".env").write_text( + "# comment\nexport DOTENV_ONLY='from_dotenv'\nBOTH_SET=\"from_dotenv_overridden\"\n" + 'INLINE_CMT=bar_val # c\nQUOTED_DBL="esc \\" a\\nb a\\$b # keep" # c\n' + "QUOTED_SGL='a\\nb' # c\nDOT_D=x#y\nDERIVED=\"${DOTENV_ONLY}/sub\"\nEMPTY_VAR=\n" + ) + for k, v in ( + ("BOTH_SET", "from_os_env"), + ("EMPTY_VAR", ""), + ("W", "wv"), + ("BARE_HOST_ONLY", "from_os_bare"), + ): + monkeypatch.setenv(k, v) + for k in ("CAPSEM_INSPECT_ALLOWED_HOST_ENV", "UNSET_VAR", "U", "V"): + monkeypatch.delenv(k, raising=False) + + compose_interp = tmp_path / "compose-interp.yaml" + compose_interp.write_text( + "services:\n app:\n image: myrepo/${DOTENV_ONLY}:${BOTH_SET}\n environment:\n" + " DEF_COLON: ${EMPTY_VAR:-colon_fallback}\n" + " DEF_PLAIN_EMPTY: ${EMPTY_VAR-plain_fallback}\n" + " DEF_PLAIN_UNSET: ${UNSET_VAR-unset_fallback}\n ALT_COLON_SET: ${W:+alt_w}\n" + " ALT_COLON_EMPTY: ${EMPTY_VAR:+alt_empty}\n ALT_COLON_UNSET: ${U:+alt_u}\n" + " ALT_PLAIN_SET: ${W+alt_plain_w}\n" + " ALT_PLAIN_EMPTY: ${EMPTY_VAR+alt_plain_empty}\n" + " ALT_PLAIN_UNSET: ${U+alt_plain_u}\n NESTED_DEF: ${U:-${V:-inner}}\n" + " BARE_VAR: $BARE_HOST_ONLY\n" + " ESCAPED_BARE: $$W\n ESCAPED: $$LITERAL_DOLLAR\n" + " FROM_INLINE: ${INLINE_CMT}\n FROM_QUOTED_DBL: ${QUOTED_DBL}\n" + " FROM_QUOTED_SGL: ${QUOTED_SGL}\n FROM_DOT_D: ${DOT_D}\n" + " FROM_DERIVED: ${DERIVED}\n" + ) + with caplog.at_level(logging.WARNING): + parsed_default = c_compose_mod.parse_host_compose_yaml_file( + compose_interp, allowed_host_env=("BOTH_SET", "W", "BARE_HOST_ONLY") + ) + assert parsed_default["services"]["app"]["image"] == "myrepo/from_dotenv:from_dotenv_overridden" + assert parsed_default["services"]["app"]["environment"]["BARE_VAR"] == "" + assert "Ignoring non-allowlisted host environment variable 'W'" in caplog.text + assert "'DOTENV_ONLY'" not in caplog.text + + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", "BOTH_SET,EMPTY_VAR,W,BARE_HOST_ONLY") + svc_app = c_compose_mod.parse_host_compose_yaml_file(compose_interp)["services"]["app"] + assert svc_app["image"] == "myrepo/from_dotenv:from_os_env" + expected_env = { + "DEF_COLON": "colon_fallback", + "DEF_PLAIN_EMPTY": "", + "DEF_PLAIN_UNSET": "unset_fallback", + "ALT_COLON_SET": "alt_w", + "ALT_COLON_EMPTY": "", + "ALT_COLON_UNSET": "", + "ALT_PLAIN_SET": "alt_plain_w", + "ALT_PLAIN_EMPTY": "alt_plain_empty", + "ALT_PLAIN_UNSET": "", + "NESTED_DEF": "inner", + "BARE_VAR": "from_os_bare", + "ESCAPED_BARE": "$W", + "ESCAPED": "$LITERAL_DOLLAR", + "FROM_INLINE": "bar_val", + "FROM_QUOTED_DBL": 'esc " a\nb a$b # keep', + "FROM_QUOTED_SGL": "a\\nb", + "FROM_DOT_D": "x#y", + "FROM_DERIVED": "from_dotenv/sub", + } + assert svc_app["environment"] == expected_env + + narrowed = c_compose_mod.parse_host_compose_yaml_file( + compose_interp, allowed_host_env=("BOTH_SET",) + ) + assert narrowed["services"]["app"]["image"] == "myrepo/from_dotenv:from_os_env" + assert narrowed["services"]["app"]["environment"]["ALT_COLON_SET"] == "" + assert narrowed["services"]["app"]["environment"]["BARE_VAR"] == "" + + for bad_pat in ("*", "**", "?*", "[*]", "[A-Z]*", "[!_]*", "*SUFFIX"): + with pytest.raises(ValueError, match="Wildcard-only or invalid pattern"): + CapsemSandboxConfig(allowed_host_env=(bad_pat,)) + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", bad_pat) + with pytest.raises(ValueError, match="Wildcard-only or invalid pattern"): + c_compose_mod.parse_host_compose_yaml_file(compose_interp) + + +def test_dotenv_syntax_errors_and_required_var_checks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("W", "wv") + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", raising=False) + for bad_dotenv in ('K="multi\n', 'K="multi\\\n', "K='multi\n"): + with pytest.raises(ValueError, match=r"Unterminated .*quoted value") as exc_info: + _load_dotenv(bad_dotenv, {}, InterpolationBudget(DEFAULT_COMPOSE_LIMITS)) + assert "multi" not in str(exc_info.value) + for bad_tail in ( + 'K="secret_v" "y"\n', + "K='secret_v' 'y'\n", + 'K="secret_v" OTHER=1\n', + 'K="secret_v" w # c\n', + ): + with pytest.raises(ValueError, match="Unexpected trailing characters") as exc_info: + _load_dotenv(bad_tail, {}, InterpolationBudget(DEFAULT_COMPOSE_LIMITS)) + assert "secret_v" not in str(exc_info.value) + + (tmp_path / ".env").write_text("EMPTY_VAR=\nSECRET_VAL=top_secret_token_999\n") + (tmp_path / "c1.yaml").write_text( + "services:\n app:\n image: ${EMPTY_VAR:?must not be empty}\n" + ) + with pytest.raises(ValueError, match="must not be empty"): + compose_mod.coerce_config(str(tmp_path / "c1.yaml")) + (tmp_path / "c_redact.yaml").write_text( + "services:\n app:\n image: ${EMPTY_VAR:?err ${SECRET_VAL}}\n" + ) + with pytest.raises(ValueError, match="Required Compose variable 'EMPTY_VAR'") as exc_info: + compose_mod.coerce_config(str(tmp_path / "c_redact.yaml")) + assert "top_secret_token_999" not in str(exc_info.value) + + for expr in ("${W:?need W}", "${W?need W}", "${W:?}"): + (tmp_path / "c2.yaml").write_text(f"services:\n app:\n image: {expr}\n") + with pytest.raises(ValueError, match=r"not allowlisted.*CAPSEM_INSPECT_ALLOWED_HOST_ENV"): + compose_mod.coerce_config(str(tmp_path / "c2.yaml")) + for bad_expr in ("${U", "prefix-${U-unclosed", "${A B}", "${}"): + (tmp_path / "c3.yaml").write_text(f"services:\n app:\n image: {bad_expr}\n") + with pytest.raises(ValueError, match="Invalid Compose variable interpolation"): + compose_mod.coerce_config(str(tmp_path / "c3.yaml")) + + (tmp_path / "c_small.yaml").write_text("services:\n app:\n image: alpine:3.20\n") + limits = ComposeLimits(maximum_bytes=10, maximum_nodes=64, maximum_depth=16) + with pytest.raises(ValueError, match="Compose input byte limit exceeded"): + c_compose_mod.parse_host_compose_yaml_file(tmp_path / "c_small.yaml", limits=limits) + + +async def test_cybergym_sample_metadata_compose_interpolation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """`SAMPLE_METADATA_*` interpolates idempotently from `metadata`, not `.env` or `os.environ`.""" + monkeypatch.setenv("SAMPLE_METADATA_HOST_LEAK", "leaked_from_host") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_ENV", "SAMPLE_*") + (tmp_path / ".env").write_text("SAMPLE_METADATA_HOST_LEAK=leaked_from_dotenv\n") + compose_path = tmp_path / "compose.yaml" + compose_path.write_text( + "services:\n default:\n image: ${SAMPLE_METADATA_IMAGE_VULNERABLE}\n environment:\n" + " - EXECUTOR_COMMAND=${SAMPLE_METADATA_EXECUTOR_COMMAND}\n - BARE_KEY\n" + " - SAMPLE_METADATA_HOST_LEAK\n - LEAK_INTERP=${SAMPLE_METADATA_HOST_LEAK}\n" + ) + pre_coerced = compose_mod.coerce_config(str(compose_path)) + ctrl = Scripted() + monkeypatch.setattr(sb_mod, "SdkCapsemController", lambda: ctrl) + metadata = { + "image_vulnerable": "cybergym/vuln-target:1.2", + "executor command": "/usr/local/bin/run-poc --fast", + "bare_key": "ignored_without_prefix", + } + with caplog.at_level(logging.WARNING): + envs = await CapsemSandboxEnvironment.sample_init("cybergym_task", pre_coerced, metadata) + try: + assert "Ignoring non-allowlisted host environment variable" not in caplog.text + assert ctrl.started[0]["image"] == "cybergym/vuln-target:1.2" + assert ctrl.started[0]["env"] == { + "EXECUTOR_COMMAND": "/usr/local/bin/run-poc --fast", + "BARE_KEY": "", + "SAMPLE_METADATA_HOST_LEAK": "", + "LEAK_INTERP": "", + } + finally: + await CapsemSandboxEnvironment.sample_cleanup("cybergym_task", None, envs, False) diff --git a/integrations/inspect-ai/tests/containers/test_compose_options.py b/integrations/inspect-ai/tests/containers/test_compose_options.py index 43448cf3f..db11ac8f7 100644 --- a/integrations/inspect-ai/tests/containers/test_compose_options.py +++ b/integrations/inspect-ai/tests/containers/test_compose_options.py @@ -147,3 +147,64 @@ def evaluator_inputs(): in caplog.text ) assert "x-inspect" not in caplog.text + + +async def test_declared_named_volumes_init_and_ports_parity( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + from typing import Any, cast + + import inspect_capsem.containers.compose_fields as compose_fields_mod + from inspect_capsem import CapsemSandboxConfig + from inspect_capsem.containers.runtime import prepare_oci_workload_container + + from ..helpers import Scripted, ok + + compose_doc = { + "volumes": {"cache-vol": None, "ro-vol": {"driver": "local"}}, + "services": { + "app": { + "image": "alpine:3.20", + "init": True, + "expose": [{"target": 8080, "protocol": "tcp"}], + "volumes": [ + "cache-vol:/var/cache/app", + { + "type": "volume", + "source": "ro-vol", + "target": "/var/lib/ro", + "read_only": True, + }, + ], + } + }, + } + with caplog.at_level(logging.WARNING): + fields = compose_fields_mod.extract_capsem_compose_fields(compose_doc, base_dir=tmp_path) + assert "Ignoring Compose 'init: true' in service 'app'" in caplog.text + assert "init" not in fields and "expose" not in fields and "ports" not in fields + assert fields["volumes"] == ("cache-vol:/var/cache/app", "ro-vol:/var/lib/ro:ro") + + cfg = CapsemSandboxConfig(**fields) + ctrl = Scripted([("", ok())]) + await prepare_oci_workload_container(cast(Any, ctrl), "vm-1", cfg.to_container_spec()) + assert ctrl.commands == [ + "mkdir -p /var/cache/app && (chmod a+rwx /var/cache/app 2>/dev/null || true)", + "mkdir -p /var/lib/ro && (chmod a+rx /var/lib/ro 2>/dev/null || true)", + ] + + for bad_top in ( + {"volumes": ["not-a-dict"], "services": {"app": {"image": "a"}}}, + {"volumes": {"/bad": {}}, "services": {"app": {"image": "a"}}}, + {"volumes": {"ext": {"external": True}}, "services": {"app": {"image": "a"}}}, + {"volumes": {"nfs": {"driver": "nfs"}}, "services": {"app": {"image": "a"}}}, + { + "volumes": {" declared": {}}, + "services": {"app": {"image": "a", "volumes": ["undeclared:/data"]}}, + }, + {"services": {"app": {"image": "a", "ports": [{"published": 8080}]}}}, + ): + with pytest.raises(ValueError): + compose_fields_mod.extract_capsem_compose_fields(bad_top, base_dir=tmp_path) + with pytest.raises(ValueError, match="ports"): + CapsemSandboxConfig.model_validate({"image": "alpine:3.20", "ports": ("8080:80",)}) diff --git a/integrations/inspect-ai/tests/containers/test_runtime.py b/integrations/inspect-ai/tests/containers/test_runtime.py new file mode 100644 index 000000000..b45482c2c --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_runtime.py @@ -0,0 +1,94 @@ +"""OCI workload container staging, healthcheck, and working_dir probing tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +import inspect_capsem.containers.runtime as rt_mod +import pytest +from inspect_capsem import CapsemSandboxConfig +from inspect_capsem.containers.runtime import ( + prepare_oci_workload_container, + resolve_container_working_dir, +) + +from ..helpers import Scripted, fail, ok + + +async def test_prepare_oci_workload_container_and_working_dir_probe( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ctrl = Scripted([("pwd", ok("/app\n"))]) + spec_default = CapsemSandboxConfig(image="alpine:3.19").to_container_spec() + await prepare_oci_workload_container(cast(Any, ctrl), "vm-1", spec_default) + assert ctrl.commands == [] + assert await resolve_container_working_dir(cast(Any, ctrl), "vm-1") == "/app" + assert ( + await resolve_container_working_dir(cast(Any, Scripted([("pwd", fail(1, ""))])), "vm-1") + == "/" + ) + + spec_explicit = CapsemSandboxConfig( + image="alpine:3.19", working_dir="/custom/work" + ).to_container_spec() + await prepare_oci_workload_container(cast(Any, ctrl), "vm-1", spec_explicit) + assert ctrl.commands == [ + "pwd", + "mkdir -p /custom/work && (chmod a+rx /custom/work 2>/dev/null || true)", + ] + + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + host_dir, host_file = tmp_path / "data", tmp_path / "single.sh" + host_dir.mkdir() + (host_dir / "hello.txt").write_text("hi\n") + host_file.write_text("#!/bin/sh\necho single\n") + host_file.chmod(0o755) + + ctrl_vols = Scripted([("", ok())]) + cfg_vols = CapsemSandboxConfig( + image="alpine:3.19", + volumes=(f"{host_dir}:/mnt/data:ro", f"{host_file}:/etc/single.sh:ro"), + allowed_host_paths=(str(tmp_path),), + ) + await prepare_oci_workload_container(cast(Any, ctrl_vols), "vm-1", cfg_vols.to_container_spec()) + assert ctrl_vols.uploads["/etc/single.sh"] == b"#!/bin/sh\necho single\n" + assert any("chmod 755 /etc/single.sh" in c for c in ctrl_vols.commands) + assert any(p.startswith("/tmp/.capsem_bind_") for p in ctrl_vols.uploads) + assert any("tar -xzf /tmp/.capsem_bind_0.tar.gz -C /mnt/data" in c for c in ctrl_vols.commands) + + spec_single = CapsemSandboxConfig( + image="alpine:3.19", + volumes=(f"{host_file}:/etc/single.sh:ro",), + allowed_host_paths=(str(tmp_path),), + ).to_container_spec() + with pytest.raises(RuntimeError, match="Failed creating parent directory"): + await prepare_oci_workload_container( + cast(Any, Scripted([("mkdir -p", fail(1, stderr="ro"))])), "vm-1", spec_single + ) + + monkeypatch.setattr(rt_mod, "_MAX_BIND_MOUNT_BYTES", 2) + for vol in (f"{host_file}:/etc/single.sh:ro", f"{host_dir}:/mnt/data:ro"): + spec = CapsemSandboxConfig( + image="alpine:3.19", volumes=(vol,), allowed_host_paths=(str(tmp_path),) + ).to_container_spec() + with pytest.raises(ValueError, match="exceeds maximum size"): + await prepare_oci_workload_container(cast(Any, ctrl_vols), "vm-1", spec) + + +async def test_oci_healthcheck_polling() -> None: + ctrl_ok = Scripted([("check_ok", ok())]) + spec_ok = CapsemSandboxConfig( + image="ubuntu:24.04", + healthcheck={"test": ["CMD-SHELL", "check_ok"], "retries": "2", "interval": "10ms"}, + ).to_container_spec() + await prepare_oci_workload_container(ctrl_ok, "vm-1", spec_ok) + assert any("sh -c check_ok" in c for c in ctrl_ok.commands) + + ctrl_fail = Scripted([("check_hc", fail(1, stderr="hc failed"))]) + spec_fail = CapsemSandboxConfig( + image="ubuntu:24.04", + healthcheck={"test": ["CMD", "check_hc"], "interval": 0.01}, + ).to_container_spec() + with pytest.raises(RuntimeError, match=r"failed healthcheck.*after 3 attempts"): + await prepare_oci_workload_container(ctrl_fail, "vm-1", spec_fail) diff --git a/integrations/inspect-ai/tests/helpers.py b/integrations/inspect-ai/tests/helpers.py index b251f5655..3ed6c1ae4 100644 --- a/integrations/inspect-ai/tests/helpers.py +++ b/integrations/inspect-ai/tests/helpers.py @@ -132,10 +132,13 @@ async def start_vm( *, cpu_count: int, ram_gb: int, + image: str | None = None, + command: Sequence[str] | None = None, env: dict[str, str] | None = None, labels: Mapping[str, str] | None = None, + registry_ca_pem: str | None = None, ) -> str: - del env + del image, command, env, registry_ca_pem if self.fail_start_vm: raise RuntimeError("start_vm failed") vm_id = f"vm-fake-{len(self.started_vms)}" diff --git a/integrations/inspect-ai/tests/live_acceptance.py b/integrations/inspect-ai/tests/live_acceptance.py index 894a3efdb..a3f197d70 100644 --- a/integrations/inspect-ai/tests/live_acceptance.py +++ b/integrations/inspect-ai/tests/live_acceptance.py @@ -1,14 +1,17 @@ -"""Live Capsem VM conformance and acceptance checks for `inspect-capsem-sandbox`.""" +"""Live Capsem VM and OCI container acceptance checks for `inspect-capsem-sandbox`.""" from __future__ import annotations import asyncio +import functools import os import sqlite3 import tempfile from pathlib import Path +from typing import Any, cast import inspect_ai.util._sandbox.self_check as inspect_self_check +import inspect_capsem.sandbox as sb_mod from capsem import Hypervisor, models from inspect_ai import Task, eval_async from inspect_ai.dataset import Sample @@ -22,6 +25,20 @@ _managed_vm_labels, ) +try: + from tests.oci_workload_fixture import hermetic_oci_workload_image +except ImportError: + import importlib.util + + _spec = importlib.util.spec_from_file_location( + "oci_workload_fixture", + Path(__file__).resolve().with_name("oci_workload_fixture.py"), + ) + assert _spec is not None and _spec.loader is not None + _mod = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_mod) + hermetic_oci_workload_image = _mod.hermetic_oci_workload_image + def _capsem_home_dir() -> Path: raw = os.environ.get("CAPSEM_HOME", "").strip() @@ -95,8 +112,68 @@ async def _verify_eval_task(hyp: Hypervisor, config: CapsemSandboxConfig, marker assert not leaked, f"eval_async leaked ephemeral managed VMs: {leaked}" +async def _verify_container_workload_mode(hyp: Hypervisor, image_ref: str, ca_pem: str) -> None: + orig_ctrl = sb_mod.SdkCapsemController + cast(Any, sb_mod).SdkCapsemController = functools.partial( + SdkCapsemController, registry_ca_pem=ca_pem + ) + try: + envs = await CapsemSandboxEnvironment.sample_init( + task_name="gate_container_acceptance", + config=CapsemSandboxConfig( + image=image_ref, cpu_count=2, ram_gb=2, working_dir="/workspace" + ), + metadata={}, + ) + vm_id = "" + try: + sb_env = envs["default"] + assert isinstance(sb_env, CapsemSandboxEnvironment) + sb, vm_id = sb_env, sb_env.vm_id + marker_res = await sb.exec(["cat", "/etc/capsem-workload-fixture"]) + assert marker_res.returncode == 0 and ( + marker_res.stdout.strip() == "capsem-hermetic-oci-fixture" + ) + exec_res = await sb.exec(["echo", "INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK"]) + assert exec_res.returncode == 0 and ( + exec_res.stdout.strip() == "INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK" + ) + await sb.write_file("/workspace/container_roundtrip.txt", "container-ok\n") + assert await sb.read_file("/workspace/container_roundtrip.txt") == "container-ok\n" + payload = bytes(range(32)) + b"\x00CONTAINER_BIN\xff" + await sb.write_file("/workspace/container.bin", payload) + assert await sb.read_file("/workspace/container.bin", text=False) == payload + await _verify_session_ledger( + hyp, + sb.vm_id, + expected_target="workload", + marker="INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK", + ) + finally: + await CapsemSandboxEnvironment.sample_cleanup( + task_name="gate_container_acceptance", + config=None, + environments=envs, + interrupted=False, + ) + await CapsemSandboxEnvironment.task_cleanup( + task_name="gate_container_acceptance", config=None, cleanup=True + ) + assert vm_id not in {m.id for m in (await hyp.list()).sandboxes}, ( + f"container sample_cleanup leaked VM {vm_id}" + ) + await _verify_eval_task( + hyp, + CapsemSandboxConfig(image=image_ref, cpu_count=1, ram_gb=1, working_dir="/workspace"), + "INSPECT_CAPSEM_CONTAINER_EVAL_OK", + ) + print("INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK") + finally: + cast(Any, sb_mod).SdkCapsemController = orig_ctrl + + async def run_live_vm_sandbox_acceptance() -> None: - """VM-backed acceptance test for the gate VM lane: init, exec, write/read_file, cleanup.""" + """VM and hermetic OCI container acceptance test for the gate VM lane.""" hyp = Hypervisor.connect() persistent_vm = await hyp.create( name=f"foreign-persistent-{os.getpid()}", @@ -179,6 +256,9 @@ async def run_live_vm_sandbox_acceptance() -> None: "INSPECT_CAPSEM_VM_EVAL_OK", ) + with hermetic_oci_workload_image(_capsem_home_dir()) as (image_ref, ca_pem): + await _verify_container_workload_mode(hyp, image_ref, ca_pem) + orphan_id = await orphan_ctrl.start_vm(cpu_count=1, ram_gb=1) by_id = {m.id: m for m in (await hyp.list()).sandboxes} assert persistent_vm.id in by_id and by_id[persistent_vm.id].persistent is True diff --git a/integrations/inspect-ai/tests/oci_workload_fixture.py b/integrations/inspect-ai/tests/oci_workload_fixture.py new file mode 100644 index 000000000..8db6f9c45 --- /dev/null +++ b/integrations/inspect-ai/tests/oci_workload_fixture.py @@ -0,0 +1,254 @@ +"""Hermetic loopback TLS OCI v2 workload image fixture built from guest initrd busybox.""" + +from __future__ import annotations + +import contextlib +import gzip +import hashlib +import http.server +import io +import json +import os +import platform +import re +import ssl +import subprocess +import tarfile +import tempfile +import threading +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +_APPLETS_RAW = ( + "sh ash sleep cat id head stat mkdir rm rmdir chmod chown tar dd env pwd echo ls cp mv ln " + "base64 cut date test [ printf true false grep sed awk find wc tr sort uniq sha256sum " + "touch uname whoami hostname ps kill setsid" +) +_APPLETS = _APPLETS_RAW.split() +_SPLIT_SH = ( + '#!/bin/sh\nshift 5\nsrc="$1"\npfx="${2:-part.}"\n' + 'if [ "$src" = "-" ]; then cat > "${pfx}000000"; else cat "$src" > "${pfx}000000"; fi\n' + '[ -s "${pfx}000000" ] || rm -f "${pfx}000000"\n' +) +_TIMEOUT_SH = ( + '#!/bin/sh\nshift 2\nsecs="${1%s}"\nshift\nflag="/tmp/.to.$$"\n' + '/bin/busybox rm -f "$flag"\nexec 3<&0\n' + '/bin/busybox setsid "$@" <&3 3<&- &\npid=$!\nexec 3<&-\n' + '/bin/busybox setsid /bin/sh -c "/bin/busybox sleep $secs; : > $flag; ' + '/bin/busybox kill -TERM -$pid 2>/dev/null; /bin/busybox kill -KILL -$pid 2>/dev/null" ' + '/dev/null 2>&1 &\nwpid=$!\nwait "$pid" 2>/dev/null\nrc=$?\n' + '/bin/busybox kill -KILL -"$wpid" 2>/dev/null || true\n' + 'if [ -e "$flag" ]; then /bin/busybox rm -f "$flag"; exit 124; fi\nexit "$rc"\n' +) +_OPENSSL_CNF = ( + "[req]\ndistinguished_name=dn\nx509_extensions=ext\nprompt=no\n" + "[dn]\nCN=Capsem hermetic fixture CA\n" + "[ext]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign\n" + "subjectKeyIdentifier=hash\n" + "[server]\nsubjectAltName=IP:127.0.0.1,DNS:localhost\n" + "basicConstraints=critical,CA:FALSE\n" + "keyUsage=critical,digitalSignature,keyEncipherment\n" + "extendedKeyUsage=serverAuth\nsubjectKeyIdentifier=hash\n" + "authorityKeyIdentifier=keyid:always\n" +) + + +def _find_initrd_path() -> Path: + arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "x86_64" + env_roots = [ + Path(v) + for k in ("CAPSEM_ASSETS_DIR", "CAPSEM_WINTERFELL_ASSETS_DIR") + if (v := os.environ.get(k, "").strip()) + ] + parents = Path(__file__).resolve().parents + roots = [ + *env_roots, + *([parents[3] / "cache/target/assets"] if len(parents) > 3 else []), + ] + for root in roots: + for candidate in (root / arch / "initrd.img", root / "initrd.img"): + if candidate.is_file(): + return candidate + if root.is_dir() and (matches := [m for m in root.rglob("initrd.img") if m.is_file()]): + return matches[0] + raise RuntimeError(f"Could not locate initrd.img in any assets root: {roots}") + + +def _extract_busybox_from_initrd(initrd_path: Path) -> bytes: + raw, offset = gzip.decompress(initrd_path.read_bytes()), 0 + while offset + 110 <= len(raw): + if raw[offset : offset + 6] not in (b"070701", b"070702"): + raise RuntimeError(f"Unexpected cpio magic at {offset} in {initrd_path}") + filesize = int(raw[offset + 54 : offset + 62], 16) + namesize = int(raw[offset + 94 : offset + 102], 16) + name_start = offset + 110 + name = raw[name_start : name_start + namesize - 1].decode("utf-8", errors="replace") + data_start = (name_start + namesize + 3) & ~3 + if name == "TRAILER!!!": + break + if name.lstrip("./") == "bin/busybox" and filesize > 0: + return raw[data_start : data_start + filesize] + offset = (data_start + filesize + 3) & ~3 + raise RuntimeError(f"bin/busybox not found in {initrd_path}") + + +def _build_rootfs_tar_bytes(busybox: bytes) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for mode, dirs in ( + (0o755, ("bin", "usr", "usr/bin", "etc", "var", "workspace")), + (0o1777, ("tmp", "var/tmp")), + (0o700, ("root", "var/tmp/sandbox-services")), + ): + for d in dirs: + ti = tarfile.TarInfo(name=d) + ti.type, ti.mode = tarfile.DIRTYPE, mode + tf.addfile(ti) + + def _add_file(name: str, data: bytes, mode: int) -> None: + info = tarfile.TarInfo(name=name) + info.size, info.mode = len(data), mode + tf.addfile(info, io.BytesIO(data)) + + def _add_symlink(name: str, target: str) -> None: + info = tarfile.TarInfo(name=name) + info.type, info.linkname, info.mode = tarfile.SYMTYPE, target, 0o777 + tf.addfile(info) + + _add_file("bin/busybox", busybox.replace(b"\x00timeout\x00", b"\x00timeou_\x00"), 0o755) + for applet in _APPLETS: + _add_symlink(f"bin/{applet}", "busybox") + _add_symlink(f"usr/bin/{applet}", "/bin/busybox") + for script_name, content in ( + ("bash", '#!/bin/sh\nexec /bin/sh "$@"\n'), + ("split", _SPLIT_SH), + ("timeout", _TIMEOUT_SH), + ): + _add_file(f"bin/{script_name}", content.encode("utf-8"), 0o755) + _add_symlink(f"usr/bin/{script_name}", f"/bin/{script_name}") + _add_file("var/tmp/sandbox-services/inspect-sandbox-tools", b"#!/bin/sh\nexit 0\n", 0o700) + _add_file("etc/capsem-workload-fixture", b"capsem-hermetic-oci-fixture\n", 0o644) + _add_file("etc/passwd", b"root:x:0:0:root:/root:/bin/sh\n", 0o644) + _add_file("etc/group", b"root:x:0:\n", 0o644) + return buf.getvalue() + + +def _grant_in_settings(home: Path, reference: str) -> None: + repo, _, _ = reference.partition("@") + path = home / "settings.toml" + text = path.read_text(encoding="utf-8") if path.exists() else "" + section = text.split("[images]", 1)[-1] if "[images]" in text else "" + + def _existing(key: str) -> set[str]: + found = re.search(rf"^{key} = \[(.*)\]$", section, re.MULTILINE) + return set(re.findall(r'"([^"]+)"', found.group(1))) if found else set() + + s_list = ", ".join(f'"{x}"' for x in sorted(_existing("sources") | {repo})) + a_list = ", ".join(f'"{x}"' for x in sorted(_existing("admit") | {reference})) + cleaned = re.sub(r"\[images\]\n(?:[^\[\n][^\n]*\n)*", "", text) + sep = "\n" if cleaned and not cleaned.endswith("\n") else "" + path.write_text( + f"{cleaned}{sep}[images]\nsources = [{s_list}]\nadmit = [{a_list}]\n", encoding="utf-8" + ) + + +@contextlib.contextmanager +def hermetic_oci_workload_image(home_dir: Path) -> Iterator[tuple[str, str]]: + """Serve a hermetic busybox OCI image over loopback TLS and admit its digest in `home_dir`.""" + raw_tar = _build_rootfs_tar_bytes(_extract_busybox_from_initrd(_find_initrd_path())) + layer_gz = gzip.compress(raw_tar, mtime=0) + diff_id = "sha256:" + hashlib.sha256(raw_tar).hexdigest() + blobs: dict[str, bytes] = {} + + def _blob(data: bytes, media_type: str) -> dict[str, Any]: + digest = "sha256:" + hashlib.sha256(data).hexdigest() + blobs[digest] = data + return {"mediaType": media_type, "digest": digest, "size": len(data)} + + oci_arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "amd64" + cfg_obj = { + "architecture": oci_arch, + "os": "linux", + "config": { + "Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + "Cmd": ["/bin/sh", "-c", "sleep 3600"], + "WorkingDir": "/workspace", + }, + "rootfs": {"type": "layers", "diff_ids": [diff_id]}, + } + cfg_bytes = json.dumps(cfg_obj, sort_keys=True).encode("utf-8") + manifest_media = "application/vnd.oci.image.manifest.v1+json" + manifest_obj = { + "schemaVersion": 2, + "mediaType": manifest_media, + "config": _blob(cfg_bytes, "application/vnd.oci.image.config.v1+json"), + "layers": [_blob(layer_gz, "application/vnd.oci.image.layer.v1.tar+gzip")], + } + manifest_bytes = json.dumps(manifest_obj, sort_keys=True).encode("utf-8") + manifest_digest = "sha256:" + hashlib.sha256(manifest_bytes).hexdigest() + + class _Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + return + + def do_HEAD(self) -> None: + self._serve(send_body=False) + + def do_GET(self) -> None: + self._serve(send_body=True) + + def _serve(self, *, send_body: bool) -> None: + path = self.path.split("?", 1)[0] + if path in ("/v2", "/v2/"): + body, kind = b"{}", "application/json" + elif path.startswith("/v2/library/workload-fixture/manifests/"): + body, kind = manifest_bytes, manifest_media + elif path.startswith("/v2/library/workload-fixture/blobs/"): + body, kind = blobs.get(path.rsplit("/", 1)[1]), "application/octet-stream" + else: + body, kind = None, "application/json" + self.send_response(200 if body is not None else 404) + payload = body or b"" + self.send_header("Content-Type", kind) + self.send_header("Content-Length", str(len(payload))) + self.send_header( + "Docker-Content-Digest", "sha256:" + hashlib.sha256(payload).hexdigest() + ) + self.end_headers() + if send_body: + self.wfile.write(payload) + + settings_path = home_dir / "settings.toml" + prev_settings = settings_path.read_text(encoding="utf-8") if settings_path.exists() else None + with tempfile.TemporaryDirectory(prefix="capsem-oci-fixture-") as tmp: + t = Path(tmp) + cnf, ca_pem, ca_key = t / "openssl.cnf", t / "ca.pem", t / "ca.key" + leaf_pem, leaf_key, csr = t / "leaf.pem", t / "leaf.key", t / "leaf.csr" + cnf.write_text(_OPENSSL_CNF, encoding="utf-8") + for cmd in ( + f"openssl req -x509 -newkey rsa:2048 -nodes -days 1 -config {cnf} -keyout {ca_key} -out {ca_pem}", + f"openssl req -new -newkey rsa:2048 -nodes -subj /CN=localhost -keyout {leaf_key} -out {csr}", + f"openssl x509 -req -in {csr} -CA {ca_pem} -CAkey {ca_key} -set_serial 1 -days 1 -extfile {cnf} -extensions server -out {leaf_pem}", + ): + subprocess.run(cmd.split(), check=True, capture_output=True, timeout=15) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ctx.load_cert_chain(leaf_pem, leaf_key) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server.socket = ctx.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + ref = f"127.0.0.1:{server.server_port}/library/workload-fixture@{manifest_digest}" + try: + home_dir.mkdir(parents=True, exist_ok=True) + _grant_in_settings(home_dir, ref) + yield f"docker://{ref}", ca_pem.read_text(encoding="utf-8") + finally: + if prev_settings is None: + settings_path.unlink(missing_ok=True) + else: + settings_path.write_text(prev_settings, encoding="utf-8") + server.shutdown() + server.server_close() + thread.join(timeout=5) diff --git a/integrations/inspect-ai/tests/test_compose_config.py b/integrations/inspect-ai/tests/test_compose_config.py new file mode 100644 index 000000000..2a48d3be1 --- /dev/null +++ b/integrations/inspect-ai/tests/test_compose_config.py @@ -0,0 +1,138 @@ +"""Inspect-facing Compose config coercion and resolution tests.""" + +from __future__ import annotations + +from pathlib import Path + +import inspect_capsem._compose as compose_mod +import inspect_capsem.sandbox as sb_mod +import pytest +from inspect_ai.util import ComposeConfig, ComposeService +from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment + +from .helpers import LocalFakeCapsemController + + +def test_coerce_config_variants(tmp_path: Path) -> None: + coerce = compose_mod.coerce_config + compose = tmp_path / "compose.yaml" + compose.write_text("services:\n default:\n image: ubuntu:24.04\n working_dir: /src\n") + cfg = coerce(CapsemSandboxConfig(compose_file=str(compose))) + assert (cfg.image, cfg.working_dir, cfg.execution_mode) == ("ubuntu:24.04", "/src", "container") + plain = CapsemSandboxConfig(image="x") + assert coerce(plain) is plain + assert coerce(CapsemSandboxConfig(compose_file="")).compose_file == "" + with pytest.raises(ValueError, match="Dockerfile / Containerfile builds"): + coerce("Dockerfile") + assert coerce(str(compose)).image == "ubuntu:24.04" + absent = str(tmp_path / "absent.yml") + with pytest.raises(FileNotFoundError, match="Compose file not found"): + coerce(absent) + assert coerce(absent, resolve_compose=False).compose_file == absent + deserialized = CapsemSandboxEnvironment.config_deserialize({"compose_file": absent}) + assert (deserialized.compose_file, deserialized.execution_mode) == (absent, "container") + moved = str(tmp_path / "moved.yaml") + dumped = CapsemSandboxConfig(compose_file=moved).model_dump(mode="json") + assert CapsemSandboxEnvironment.config_deserialize(dumped).compose_file == moved + assert coerce("python:3.12-slim").image == "python:3.12-slim" + with pytest.raises(FileNotFoundError): + compose_mod.resolve_compose_file(CapsemSandboxConfig(compose_file=absent)) + (tmp_path / "empty.yaml").write_text("services: {}\n") + with pytest.raises(ValueError, match="non-empty 'services' mapping"): + compose_mod.resolve_compose_file( + CapsemSandboxConfig(compose_file=str(tmp_path / "empty.yaml")) + ) + (tmp_path / "capsem.yaml").write_text("execution_mode: vm\n") + with pytest.raises(ValueError, match="non-empty 'services' mapping"): + coerce(str(tmp_path / "capsem.yaml")) + assert "capsem.yaml" not in CapsemSandboxEnvironment.config_files() + assert "capsem.yml" not in CapsemSandboxEnvironment.config_files() + + +def test_compose_file_config_applies_service_fields( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Compose file parses service image, working_dir, env, command, volumes, limits.""" + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + host_dir = tmp_path / "mounted_dir" + host_dir.mkdir() + (host_dir / "file.txt").write_text("hello", encoding="utf-8") + compose_path = tmp_path / "compose.yaml" + compose_path.write_text( + "services:\n default:\n image: ubuntu:24.04\n working_dir: /app\n" + " environment:\n FOO: bar\n NUM: 42\n" + " command: ['python3', '-m', 'http.server', '8080']\n entrypoint: ['/bin/sh', '-c']\n" + " volumes:\n - ./mounted_dir:/mnt/data:ro\n mem_limit: 512m\n cpus: '2'\n" + " user: '1000:1000'\n healthcheck:\n test: ['CMD-SHELL', 'true']\n" + " interval: 1s\n retries: 2\n" + ) + cfg = compose_mod.coerce_config(str(compose_path)) + assert (cfg.execution_mode, cfg.image, cfg.working_dir) == ("container", "ubuntu:24.04", "/app") + assert cfg.to_container_spec().working_dir_explicit is True + assert cfg.environment == {"FOO": "bar", "NUM": "42"} + assert cfg.command == ("/bin/sh", "-c", "python3", "-m", "http.server", "8080") + assert cfg.volumes == (f"{host_dir.resolve()}:/mnt/data:ro",) + assert (cfg.mem_limit, cfg.ram_gb, cfg.cpu_count, cfg.user) == ("512m", 1, 2, "1000:1000") + assert cfg.healthcheck == {"test": ["CMD-SHELL", "true"], "interval": "1s", "retries": 2} + + explicit_cfg = CapsemSandboxConfig( + compose_file=str(compose_path), image="python:3.11-slim", working_dir="/workspace" + ) + resolved = compose_mod.resolve_compose_file(explicit_cfg) + assert (resolved.image, resolved.working_dir) == ("python:3.11-slim", "/workspace") + + for k8s_yaml in ( + "services:\n default:\n image: u\n" + " x-inspect_k8s_sandbox:\n allow_domains: [a]\n", + "x-inspect_k8s_sandbox:\n allow_domains: [a]\nservices:\n default:\n image: u\n", + ): + (tmp_path / "k8s.yaml").write_text(k8s_yaml) + with pytest.raises(NotImplementedError, match="allow_domains"): + compose_mod.coerce_config(str(tmp_path / "k8s.yaml")) + + with pytest.raises(ValueError, match="allowed_host_paths"): + compose_mod.coerce_config({"image": "alpine:3.20", "volumes": [f"{host_dir}:/mnt:ro"]}) + with pytest.raises(ValueError, match="allowed_host_paths"): + compose_mod.coerce_config( + ComposeConfig(services={"default": ComposeService(image="a", volumes=["./rel:/mnt"])}) + ) + + coerced_cc = compose_mod.coerce_config( + ComposeConfig( + services={ + "default": ComposeService(image="python:3.12-slim", working_dir="/srv", user="1000") + } + ) + ) + assert (coerced_cc.execution_mode, coerced_cc.image, coerced_cc.working_dir) == ( + "container", + "python:3.12-slim", + "/srv", + ) + assert coerced_cc.to_container_spec().working_dir_explicit is True + + for df_str in ("Dockerfile", "Containerfile", "path/to/Custom.Dockerfile", "dev.containerfile"): + with pytest.raises(ValueError, match="Dockerfile / Containerfile builds"): + compose_mod.coerce_config(df_str) + for bad_dict_key in ("dockerfile", "build", "build_context", "build_args", "build_target"): + with pytest.raises(ValueError, match=bad_dict_key): + compose_mod.coerce_config({bad_dict_key: "x"}) + + +async def test_multi_service_compose_rejected_in_sample_init( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Multi-service compose.yaml and ComposeConfig fail with ValueError before starting a VM.""" + controller = LocalFakeCapsemController(tmp_path) + monkeypatch.setattr(sb_mod, "SdkCapsemController", lambda: controller) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services:\n v:\n image: v:1\n k:\n image: k:1\n") + for bad_cfg in ( + CapsemSandboxConfig(compose_file=str(compose_path)), + ComposeConfig(services={"a": ComposeService(image="a"), "b": ComposeService(image="b")}), + ): + with pytest.raises(ValueError, match="Multi-service"): + await CapsemSandboxEnvironment.sample_init( + task_name="multi", config=bad_cfg, metadata={} + ) + assert controller.started_vms == [] diff --git a/integrations/inspect-ai/tests/test_config.py b/integrations/inspect-ai/tests/test_config.py index 925d4a2b0..fc3f4be40 100644 --- a/integrations/inspect-ai/tests/test_config.py +++ b/integrations/inspect-ai/tests/test_config.py @@ -10,11 +10,21 @@ import pytest from inspect_ai.util._sandbox.registry import registry_find_sandboxenv from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment +from inspect_capsem._compose import coerce_config from inspect_capsem._controller import SdkCapsemController, is_root_user_spec from inspect_capsem._exec import _format_exec_command -from inspect_capsem.config import coerce_config +from inspect_capsem.containers.compose_fields import ( + _is_host_path_allowed, + extract_capsem_compose_fields, +) +from inspect_capsem.containers.runtime import ( + _stage_oci_bind_volumes, + prepare_oci_workload_container, +) from pydantic import ValidationError +from .helpers import Scripted, ok + def test_package_exports_and_registry() -> None: assert inspect_capsem.__all__ == ["CapsemSandboxConfig", "CapsemSandboxEnvironment"] @@ -58,12 +68,101 @@ def test_coerce_config_and_unsupported_knobs() -> None: coerce_config({"allow_domains": ["pypi.org"]}) with pytest.raises(NotImplementedError, match="host_workspace_dir"): coerce_config({"host_workspace_dir": "/tmp/ws"}) + with pytest.raises(ValidationError, match="requires an explicit OCI image reference"): + CapsemSandboxConfig(execution_mode="container") + with pytest.raises(ValueError, match="requires an explicit OCI image reference"): + CapsemSandboxConfig(compose_file="compose.yaml").to_container_spec() with pytest.raises(ValidationError): coerce_config({"pool_endpoint": "http://127.0.0.1:9999"}) with pytest.raises(TypeError, match="Unsupported Capsem sandbox config type"): coerce_config(cast(Any, object())) +async def test_host_path_containment_and_operator_allowlist_plumbing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + compose_dir, sibling_dir, outside_dir = tmp_path / "a", tmp_path / "ab", tmp_path / "outside" + for d in (compose_dir, sibling_dir, outside_dir): + d.mkdir() + outside_file, sibling_file = outside_dir / "secret.txt", sibling_dir / "sibling.txt" + outside_file.write_text("secret") + sibling_file.write_text("sibling") + escape_dir_link, escape_file_link = compose_dir / "escape_dir", compose_dir / "escape.txt" + escape_dir_link.symlink_to(outside_dir) + escape_file_link.symlink_to(outside_file) + + for p in (escape_dir_link, escape_file_link, sibling_dir, sibling_file): + assert not _is_host_path_allowed(p, (str(compose_dir),), base_dir=compose_dir) + + allow_link = tmp_path / "allow_link" + allow_link.symlink_to(outside_dir) + assert _is_host_path_allowed(outside_file, (str(allow_link),)) + assert not _is_host_path_allowed(tmp_path / "other.txt", (str(allow_link),)) + monkeypatch.chdir(tmp_path) + assert _is_host_path_allowed(outside_file, ("outside",)) + assert not _is_host_path_allowed(sibling_file, ("outside",)) + + monkeypatch.setenv("HOME", str(outside_dir)) + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + for bad_vol in ( + "./escape_dir:/mnt", + "./escape.txt:/mnt/s.txt", + "../ab:/mnt", + "../outside/secret.txt:/mnt/s.txt", + f"{outside_dir}:/mnt", + "~/secret.txt:/mnt/s.txt", + {"type": "bind", "source": "nonexistent_in_cwd", "target": "/mnt"}, + ): + doc = {"services": {"app": {"image": "alpine:3.20", "volumes": [bad_vol]}}} + with pytest.raises(ValueError, match="allowed_host_paths"): + extract_capsem_compose_fields(doc, base_dir=compose_dir) + + vol_doc = {"services": {"app": {"image": "a", "volumes": [f"{outside_file}:/mnt/s.txt:ro"]}}} + allow_out = (str(outside_dir),) + with pytest.raises(ValueError, match="allowed_host_paths"): + extract_capsem_compose_fields(vol_doc, base_dir=compose_dir, allowed_host_paths=allow_out) + + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", f"{outside_dir},{sibling_dir}") + ext_ok = extract_capsem_compose_fields( + vol_doc, base_dir=compose_dir, allowed_host_paths=allow_out + ) + assert ext_ok["volumes"] == (f"{outside_file.resolve()}:/mnt/s.txt:ro",) + + sib_doc = {"services": {"app": {"image": "a", "volumes": [f"{sibling_file}:/mnt/sib.txt:ro"]}}} + with pytest.raises(ValueError, match="allowed_host_paths"): + extract_capsem_compose_fields(sib_doc, base_dir=compose_dir, allowed_host_paths=allow_out) + + allow_cmp = (str(compose_dir),) + with pytest.raises(ValueError, match="allowed_host_paths"): + await _stage_oci_bind_volumes( + cast(Any, Scripted()), + "vm-1", + (f"{escape_file_link}:/mnt/s.txt",), + allowed_host_paths=allow_cmp, + ) + + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + spec_unallowed = CapsemSandboxConfig( + execution_mode="container", + image="alpine:3.20", + volumes=(f"{outside_file}:/mnt/secret.txt",), + allowed_host_paths=allow_out, + ).to_container_spec() + assert spec_unallowed.allowed_host_paths == () + with pytest.raises(ValueError, match="allowed_host_paths"): + await prepare_oci_workload_container(Scripted(), "vm-1", spec_unallowed) + + in_dir = compose_dir / "data" + in_dir.mkdir() + (in_dir / "in.txt").write_text("in") + compose_path = compose_dir / "compose.yaml" + compose_path.write_text( + "services:\n app:\n image: alpine:3.20\n volumes:\n - ./data:/mnt/data:ro\n" + ) + spec_ok = coerce_config(str(compose_path)).to_container_spec() + await prepare_oci_workload_container(Scripted([("", ok())]), "vm-1", spec_ok) + + def test_user_spec_quoting_and_mixed_groups() -> None: assert is_root_user_spec("0:0") and is_root_user_spec("root:root") assert not is_root_user_spec("0:1000") and not is_root_user_spec("root:nogroup") diff --git a/integrations/inspect-ai/tests/test_containers_boundary.py b/integrations/inspect-ai/tests/test_containers_boundary.py new file mode 100644 index 000000000..749498a51 --- /dev/null +++ b/integrations/inspect-ai/tests/test_containers_boundary.py @@ -0,0 +1,79 @@ +"""Container boundary tests (`inspect_capsem/containers` imports and lazy VM mode).""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +from inspect_capsem import CapsemSandboxConfig +from inspect_capsem.containers.spec import ContainerSpec + +CONTAINERS_DIR = Path(__file__).resolve().parents[1] / "inspect_capsem" / "containers" +ALLOWED_PREFIX = ("inspect_capsem.containers",) + + +def _imported_modules(py_file: Path) -> set[str]: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + found.add(node.module) + return found + + +def test_containers_package_has_no_inspect_or_parent_imports() -> None: + py_files = sorted(CONTAINERS_DIR.glob("*.py")) + assert py_files, f"Expected Python modules under {CONTAINERS_DIR}" + for py_file in py_files: + for mod in _imported_modules(py_file): + assert not mod.startswith("inspect_ai"), f"{py_file.name} imported {mod!r}" + if mod.startswith("inspect_capsem"): + assert mod.startswith(ALLOWED_PREFIX), f"{py_file.name} imported {mod!r}" + + +def test_vm_mode_does_not_import_containers_at_top_level() -> None: + code = ( + "import sys, inspect_capsem; " + "loaded = [m for m in sys.modules if m.startswith('inspect_capsem.containers')]; " + "assert not loaded, f'Unexpected container modules loaded on import: {loaded}'" + ) + proc = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=False) + assert proc.returncode == 0, proc.stderr or proc.stdout + + +def test_to_container_spec_roundtrip(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + compose_path = tmp_path / "sub" / "compose.yaml" + compose_path.parent.mkdir() + cfg = CapsemSandboxConfig( + execution_mode="container", + image="ubuntu:24.04", + working_dir="/app", + compose_file=str(compose_path), + environment={"K": "V"}, + command=("echo", "hi"), + volumes=(f"{tmp_path}:/mnt:ro",), + healthcheck={"test": ["CMD", "true"]}, + mem_limit="512m", + user="1000:1000", + allowed_host_paths=(str(tmp_path),), + ) + spec = cfg.to_container_spec() + assert isinstance(spec, ContainerSpec) and (spec.image, spec.working_dir) == ( + "ubuntu:24.04", + "/app", + ) + assert spec.working_dir_explicit is True + assert {str(compose_path.parent.resolve()), str(tmp_path.resolve())} <= set( + spec.allowed_host_paths + ) + assert ( + CapsemSandboxConfig(execution_mode="container", image="ubuntu:24.04") + .to_container_spec() + .working_dir_explicit + is False + ) diff --git a/integrations/inspect-ai/tests/test_controller.py b/integrations/inspect-ai/tests/test_controller.py index d3b456e0c..0d618c480 100644 --- a/integrations/inspect-ai/tests/test_controller.py +++ b/integrations/inspect-ai/tests/test_controller.py @@ -145,6 +145,12 @@ def _lbl(prefix: str = "") -> dict[str, str]: } vid = await ctrl.start_vm(cpu_count=2, ram_gb=4) assert vid == "vm-ephemeral-1" and last_create["labels"] == _lbl("bench-a") + ctrl._registry_ca_pem = "CA-CTOR" + await ctrl.start_vm(cpu_count=1, ram_gb=1, image="b:1") + assert last_create["registry"].ca_pem == "CA-CTOR" + await ctrl.start_vm(cpu_count=1, ram_gb=1, image="b:1", registry_ca_pem="CA-ARG") + assert last_create["registry"].ca_pem == "CA-ARG" + ctrl._registry_ca_pem = None for raw, slug in ( ("inspect-capsem-run_2/x-", "run-2-x"), diff --git a/integrations/inspect-ai/tests/test_controller_exec.py b/integrations/inspect-ai/tests/test_controller_exec.py index 2d0cb01c7..eca9b649f 100644 --- a/integrations/inspect-ai/tests/test_controller_exec.py +++ b/integrations/inspect-ai/tests/test_controller_exec.py @@ -60,9 +60,12 @@ async def fake_exec( vid = await ctrl.start_vm(cpu_count=1, ram_gb=1) await ctrl.exec_in_vm(vid, "true", timeout=910) await ctrl.exec_in_vm(vid, "true", timeout=3610) + vid_oci = await ctrl.start_vm(cpu_count=1, ram_gb=1, image="alpine:3.19") + await ctrl.exec_in_vm(vid_oci, "true", timeout=60) assert calls == [ (910, models.ExecTarget.VM), (3600, models.ExecTarget.VM), + (60, models.ExecTarget.WORKLOAD), ] finally: await ctrl.close() @@ -134,6 +137,8 @@ async def fake_exec( async def test_sdk_controller_with_async_capsem_hypervisor_and_vm() -> None: + seen_targets: list[Any] = [] + class FakeAsyncVM: id = "vm-async-123" name = "sdk-vm-test" @@ -143,7 +148,7 @@ async def exec( self, command: str, *, timeout_secs: int | None = None, target: Any = None ) -> object: del timeout_secs - assert target == models.ExecTarget.VM + seen_targets.append(target) return exec_response(0, stdout=f"ran:{command}\n") async def delete(self) -> None: @@ -157,15 +162,8 @@ def vm(self, *, name: str | None = None, id: str | None = None) -> FakeAsyncVM: del name, id return self._vm - async def create( - self, - *, - name: str = "", - cpus: int | None = None, - memory: int | None = None, - labels: Any = None, - ) -> FakeAsyncVM: - del name, cpus, memory, labels + async def create(self, **kwargs: Any) -> FakeAsyncVM: + del kwargs return self._vm async def close(self) -> None: @@ -180,17 +178,16 @@ async def close(self) -> None: assert res.exit_code == 0 and res.stdout == "ran:uname -a\n" await ctrl.stop_vm(vid) assert hv._vm.deleted is True + vid_oci = await ctrl.start_vm(cpu_count=2, ram_gb=4, image="alpine:3.19") + await ctrl.exec_in_vm(vid_oci, "id", timeout=30) + assert seen_targets == [models.ExecTarget.VM, models.ExecTarget.WORKLOAD] finally: await ctrl.close() def test_exec_output_text_decodes_sdk_streams() -> None: - assert ( - decode_exec_output(ExecOutput(data="hé\n", encoding=ExecOutputEncoding.UTF8)).decode( - "utf-8", errors="replace" - ) - == "hé\n" - ) + utf8 = ExecOutput(data="hé\n", encoding=ExecOutputEncoding.UTF8) + assert decode_exec_output(utf8).decode("utf-8", errors="replace") == "hé\n" b64 = ExecOutput(data="aGk=", encoding=ExecOutputEncoding.BASE64) assert decode_exec_output(b64).decode("utf-8", errors="replace") == "hi" @@ -198,6 +195,7 @@ def test_exec_output_text_decodes_sdk_streams() -> None: async def test_sdk_controller_reuses_persistent_event_loop_with_aiohttp() -> None: """SdkCapsemController reuses a persistent event loop across real Hypervisor aiohttp calls.""" seen_targets: list[Any] = [] + created_count = 0 class _Handler(http.server.BaseHTTPRequestHandler): def _send_json(self, payload: dict[str, Any]) -> None: @@ -209,12 +207,13 @@ def _send_json(self, payload: dict[str, Any]) -> None: self.wfile.write(raw) def do_POST(self) -> None: + nonlocal created_count length = int(self.headers.get("Content-Length", "0")) body = json.loads(self.rfile.read(length).decode("utf-8")) if length else {} if self.path == "/vms/create": - self._send_json( - {"id": "sb-1", "name": "sb-1", "status": "Running", "available_actions": []} - ) + created_count += 1 + s = f"sb-{created_count}" + self._send_json({"id": s, "name": s, "status": "Running", "available_actions": []}) elif self.path.endswith("/exec"): seen_targets.append(body.get("target")) out = {"data": f"ok:{body.get('command', '')}\n", "encoding": "utf8"} @@ -240,8 +239,12 @@ def log_message(self, format: str, *args: object) -> None: assert res1.exit_code == 0 and res1.stdout == "ok:echo hi\n" res2 = await ctrl.exec_in_vm(vid, "echo second", timeout=10) assert res2.exit_code == 0 and res2.stdout == "ok:echo second\n" - assert seen_targets == ["vm", "vm"] + vid_oci = await ctrl.start_vm(cpu_count=2, ram_gb=4, image="alpine:3.19") + assert vid_oci == "sb-2" + await ctrl.exec_in_vm(vid_oci, "echo oci", timeout=10) + assert seen_targets == ["vm", "vm", "workload"] await ctrl.stop_vm(vid) + await ctrl.stop_vm(vid_oci) finally: await ctrl.close() srv.shutdown() diff --git a/integrations/inspect-ai/tests/test_sandbox.py b/integrations/inspect-ai/tests/test_sandbox.py index 71f9ddfcb..5a630ef2e 100644 --- a/integrations/inspect-ai/tests/test_sandbox.py +++ b/integrations/inspect-ai/tests/test_sandbox.py @@ -13,7 +13,7 @@ from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment from inspect_capsem._controller import CommandResult -from .helpers import LocalFakeCapsemController, Scripted, env_for, init_env +from .helpers import LocalFakeCapsemController, Scripted, init_env async def test_environment_properties_and_connection() -> None: @@ -21,23 +21,41 @@ async def test_environment_properties_and_connection() -> None: original = sb.SdkCapsemController cast(Any, sb).SdkCapsemController = lambda: ctrl try: - default = CapsemSandboxEnvironment("vm-2") + default = CapsemSandboxEnvironment("vm-2", execution_mode="vm") assert default.vm_id == "vm-2" and default._controller is ctrl + assert default.execution_mode == "vm" finally: cast(Any, sb).SdkCapsemController = original - assert CapsemSandboxEnvironment.config_files() == [] - assert not CapsemSandboxEnvironment.is_docker_compatible() + assert "Dockerfile" in CapsemSandboxEnvironment.config_files() + assert "capsem.yaml" not in CapsemSandboxEnvironment.config_files() + assert CapsemSandboxEnvironment.is_docker_compatible() assert CapsemSandboxEnvironment.default_concurrency() == 4 - conn = await env_for(ctrl).connection() + container = CapsemSandboxEnvironment("vm-1", ctrl, execution_mode="container") + conn = await container.connection(user="bob") assert conn.type == "capsem" - assert conn.command == "capsem shell vm-s" + assert conn.command == "capsem shell vm-1" assert conn.container is None -async def test_sample_init_vm_mode() -> None: +async def test_sample_init_container_and_vm_modes() -> None: + ctrl = Scripted() + env = await init_env( + ctrl, + CapsemSandboxConfig( + execution_mode="container", + image="ubuntu:24.04", + healthcheck={"test": ["CMD-SHELL", "true"], "retries": 1, "interval": "10ms"}, + ), + ) + assert (env.vm_id, env.execution_mode) == ("vm-s", "container") + assert any("sh -c true" in c for c in ctrl.commands) + await env.cleanup() + assert ctrl.stopped == ["vm-s"] + ctrl = Scripted() env = await init_env(ctrl, CapsemSandboxConfig(environment={"FOO": "bar"})) assert env.vm_id == "vm-s" + assert env.execution_mode == "vm" assert ctrl.started[0]["env"] == {"FOO": "bar"} assert ctrl.started[0]["labels"]["managed-by"] == "inspect-capsem" assert ctrl.started[0]["labels"]["inspect-capsem-task"] == "t" diff --git a/integrations/inspect-ai/tests/test_sandbox_container.py b/integrations/inspect-ai/tests/test_sandbox_container.py new file mode 100644 index 000000000..54b14ed42 --- /dev/null +++ b/integrations/inspect-ai/tests/test_sandbox_container.py @@ -0,0 +1,265 @@ +"""OCI container mode initialization, working_dir resolution, and exec tests.""" + +from __future__ import annotations + +import os +import subprocess as sp +from pathlib import Path + +import inspect_capsem._controller as ctrl_mod +import inspect_capsem._exec as exec_mod +import inspect_capsem._lifecycle as lc_mod +import inspect_capsem.sandbox as sb_mod +import pytest +from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment +from inspect_capsem._files import _chown_to_container_user_snippet + +from .helpers import ( + LocalFakeCapsemController, + Scripted, + _host_timeout_skips, + _run_inspect_self_check, + env_for, + fail, + ok, +) + + +def test_oci_create_command_and_image_normalization() -> None: + assert ctrl_mod._normalize_image_ref(None) is None + assert ctrl_mod._normalize_image_ref(" ") is None + assert ctrl_mod._normalize_image_ref("python:3.12-slim") == "docker://python:3.12-slim" + assert ctrl_mod._normalize_image_ref("docker://alpine:3.19") == "docker://alpine:3.19" + assert lc_mod._oci_create_command(CapsemSandboxConfig(image="alpine:3.19")) is None + assert ( + lc_mod._oci_create_command(CapsemSandboxConfig(image="alpine:3.19", command=" ")) is None + ) + assert lc_mod._oci_create_command( + CapsemSandboxConfig(image="alpine:3.19", command="python3 -m http.server 8080") + ) == ("python3", "-m", "http.server", "8080") + assert lc_mod._oci_create_command( + CapsemSandboxConfig(image="alpine:3.19", command=("sleep", "infinity")) + ) == ("sleep", "infinity") + + +async def test_container_mode_uses_oci_create_and_resolves_workdir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Container mode passes image/command to start_vm and probes WORKDIR when omitted.""" + fake = Scripted([("pwd", ok("/app/src\n"))]) + monkeypatch.setattr(sb_mod, "SdkCapsemController", lambda: fake) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services:\n default:\n image: swe-bench/eval:1.0\n") + envs = await CapsemSandboxEnvironment.sample_init( + task_name="oci_default_wd", config=str(compose_path), metadata={} + ) + try: + sb = envs["default"] + assert isinstance(sb, CapsemSandboxEnvironment) + assert sb.execution_mode == "container" + assert fake.started[-1]["image"] == "swe-bench/eval:1.0" + assert not any(c.startswith("docker ") for c in fake.commands) + fake.commands.clear() + await sb.exec(["pytest", "-q"]) + assert len(fake.commands) == 1 and fake.commands[0].startswith("bash -c ") + assert "cd /app/src && pytest -q" in fake.commands[0] + assert exec_mod._CONTAINER_HOME_FIX in fake.commands[0] + fake.commands.clear() + await sb.exec(["pytest", "-q"], user="1000:1000", timeout=None) + assert len(fake.commands) == 1 and not fake.commands[0].startswith("bash -c ") + finally: + await CapsemSandboxEnvironment.sample_cleanup( + task_name="oci_default_wd", config=None, environments=envs, interrupted=False + ) + + +async def test_oci_container_write_file_chowns_to_nonroot_user( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + """OCI container write_file chowns to cfg.user or /proc/1 and warns on failure.""" + bin_dir, chown_log = tmp_path / "bin", tmp_path / "chown.log" + bin_dir.mkdir() + fake_chown = bin_dir / "chown" + fake_chown.write_text(f'#!/bin/sh\necho "$@" >> "{chown_log}"\n') + fake_chown.chmod(0o755) + env = {**os.environ, "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}"} + + for cfg_user, arg, expected in ( + ("developer", "imageuser", "developer: /home/dev/file.py"), + ("", "1000:1000", "1000:1000 /home/dev/file.py"), + ): + snip = _chown_to_container_user_snippet("'/home/dev/file.py'", cfg_user) + sp.run(["/bin/sh", "-c", snip, "sh", arg], env=env, check=True) + assert chown_log.read_text().strip() == expected + chown_log.unlink() + + snip_root = _chown_to_container_user_snippet("'/root/file.py'", "root") + sp.run(["/bin/sh", "-c", snip_root, "sh", "1000:1000"], env=env, check=True) + assert not chown_log.exists() + + ctrl_oci = Scripted([("", ok())]) + sb_oci = env_for(ctrl_oci, execution_mode="container", user="1000:1000") + await sb_oci.write_file("/workspace/fix.py", "x = 1\n") + assert all(s in "\n".join(ctrl_oci.commands) for s in ("/proc/1", "chown ", "1000:1000")) + + caplog.clear() + ctrl_warn = Scripted([("chown", fail(1, stderr="chown: invalid group"))]) + sb_warn = env_for(ctrl_warn, execution_mode="container", user="bad:group") + await sb_warn.write_file("/workspace/b.py", "1") + assert "Failed to chown /workspace/b.py in container" in caplog.text + + +async def test_container_mode_passes_self_check_with_local_fake_controller( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`execution_mode='container'` passes Inspect `self_check` via `LocalFakeCapsemController`.""" + controller = LocalFakeCapsemController(tmp_path, skip_bake=False) + monkeypatch.setattr(sb_mod, "SdkCapsemController", lambda: controller) + guest_work = tmp_path / "container_work" + guest_work.mkdir() + cfg = CapsemSandboxConfig( + execution_mode="container", image="ubuntu:24.04", working_dir=str(guest_work) + ) + envs = await CapsemSandboxEnvironment.sample_init( + task_name="self_check_container_mode", config=cfg, metadata={} + ) + env = envs["default"] + assert isinstance(env, CapsemSandboxEnvironment) + assert env.execution_mode == "container" + try: + skip = { + "test_read_and_write_large_file_binary", + "test_exec_input_large", + "test_read_file_limit", + "test_exec_as_user", + } | _host_timeout_skips() + results = await _run_inspect_self_check(env, skip=skip) + failures = {k: v for k, v in results.items() if v is not True} + assert failures == {}, f"Inspect container self_check failures: {failures}" + finally: + await CapsemSandboxEnvironment.sample_cleanup( + task_name="self_check_container_mode", config=None, environments=envs, interrupted=False + ) + + +def test_format_exec_command_user_specs_at_and_not_at_target_identity(tmp_path: Path) -> None: + """All 4 user spec forms skip su/setpriv at target identity and switch when not at target.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + for name, body in ( + ( + "id", + '#!/bin/sh\nflag="$1"; target="${2:-}"\n' + 'if [ -z "$target" ]; then\n' + ' [ "$flag" = "-u" ] && echo "${FAKE_UID:-1000}" && exit 0\n' + ' [ "$flag" = "-g" ] && echo "${FAKE_GID:-1000}" && exit 0\n' + ' [ "$flag" = "-un" ] && echo "appuser" && exit 0\n' + "fi\n" + 'if [ "$target" = "appuser" ] || [ "$target" = "1000" ]; then\n' + ' [ "$flag" = "-un" ] && echo "appuser" || echo "1000"\n' + " exit 0\n" + "fi\n" + 'if [ "$target" = "otheruser" ]; then echo "2000"; exit 0; fi\n' + "exit 1\n", + ), + ( + "getent", + '#!/bin/sh\n[ "$1:$2" = "group:appgroup" ] && echo "appgroup:x:1000:" && exit 0\n' + '[ "$1:$2" = "passwd:1000" ] && echo "appuser:x:1000:1000::/home/appuser:/bin/sh" ' + "&& exit 0\nexit 2\n", + ), + ("su", '#!/bin/sh\n[ "${DENY_SWITCH:-0}" = "1" ] && exit 99\nprintf "su:%s\\n" "$*"\n'), + ( + "setpriv", + '#!/bin/sh\n[ "${DENY_SWITCH:-0}" = "1" ] && exit 99\nprintf "setpriv:%s\\n" "$*"\n', + ), + ): + p = bin_dir / name + p.write_text(body, encoding="utf-8") + p.chmod(0o755) + + base_env = {**os.environ, "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}"} + at_env = {**base_env, "FAKE_UID": "1000", "FAKE_GID": "1000", "DENY_SWITCH": "1"} + for spec in ("1000", "1000:1000", "appuser", "appuser:appgroup", "1000:appgroup"): + script, _ = exec_mod._format_exec_command( + 'printf "%s:%s:%s" "$USER" "$LOGNAME" "$HOME"', + effective_cwd="/", + env=None, + user=spec, + timeout=None, + ) + res = sp.run(["/bin/sh", "-c", script], env=at_env, check=True, capture_output=True) + assert res.stdout.decode() == "appuser:appuser:/home/appuser" + + for mismatch_spec in ("2000", "2000:2000", "otheruser", "otheruser:appgroup", "1000:2000"): + script, _ = exec_mod._format_exec_command( + "true", effective_cwd="/", env=None, user=mismatch_spec, timeout=None + ) + deny_res = sp.run(["/bin/sh", "-c", script], env=at_env, check=False, capture_output=True) + assert deny_res.returncode == 126 + assert "no-new-privileges" in deny_res.stderr.decode() + + for root_spec in ("root", "0", "0:0", "root:root"): + inner, _ = exec_mod._format_exec_command( + "printf ok", effective_cwd="/", env=None, user=root_spec, timeout=None + ) + wrapped = exec_mod._wrap_target_command(inner, is_container=True, user=root_spec) + assert exec_mod._CONTAINER_ROOT_CHECK in wrapped + deny_root = sp.run(["/bin/sh", "-c", wrapped], env=at_env, check=False, capture_output=True) + assert deny_root.returncode == 126 + assert "cannot switch to root inside a non-root workload" in deny_root.stderr.decode() + + root_env = {**base_env, "FAKE_UID": "0", "FAKE_GID": "0", "DENY_SWITCH": "0"} + ok_root = sp.run( + ["/bin/sh", "-c", exec_mod._wrap_target_command("printf ok", is_container=True, user="0")], + env=root_env, + check=True, + capture_output=True, + ) + assert ok_root.stdout.decode() == "ok" + + for spec, expected_prefix in ( + ("1000", "su:-m appuser -s /bin/bash -c "), + ("2000", "setpriv:--reuid=2000 --regid=0 --clear-groups /bin/bash -c "), + ("1000:1000", "setpriv:--reuid=1000 --regid=1000 --clear-groups /bin/bash -c "), + ("appuser", "su:-m appuser -s /bin/bash -c "), + ("appuser:appgroup", "setpriv:--reuid=1000 --regid=1000 --clear-groups /bin/bash -c "), + ): + script, _ = exec_mod._format_exec_command( + "true", effective_cwd="/", env=None, user=spec, timeout=None + ) + res = sp.run(["/bin/sh", "-c", script], env=root_env, check=True, capture_output=True) + assert res.stdout.decode().startswith(expected_prefix) + + for bad_spec, err_msg in (("ghost", "unknown user"), ("appuser:ghostgrp", "unknown group")): + script, _ = exec_mod._format_exec_command( + "true", effective_cwd="/", env=None, user=bad_spec, timeout=None + ) + bad_res = sp.run(["/bin/sh", "-c", script], env=root_env, check=False, capture_output=True) + assert bad_res.returncode == 1 and err_msg in bad_res.stderr.decode() + + +async def test_container_nonroot_user_switch_maps_to_permission_error() -> None: + """In container mode, exit 126 with no-new-privileges raises PermissionError.""" + ctrl = Scripted( + [ + ( + "cannot switch to root", + fail( + 126, + stderr="capsem: cannot switch to root inside a non-root workload (no-new-privileges)\n", + ), + ), + ( + "cannot switch user", + fail( + 126, + stderr="capsem: cannot switch user inside a non-root workload (no-new-privileges)\n", + ), + ), + ] + ) + sb = env_for(ctrl, execution_mode="container") + for requested_user in ("root", "0", "2000", "2000:2000", "nobody"): + with pytest.raises(PermissionError, match="no-new-privileges"): + await sb.exec(["id", "-u"], user=requested_user) diff --git a/integrations/inspect-ai/tests/test_sandbox_exec.py b/integrations/inspect-ai/tests/test_sandbox_exec.py index aa5d0767f..ec0be0d87 100644 --- a/integrations/inspect-ai/tests/test_sandbox_exec.py +++ b/integrations/inspect-ai/tests/test_sandbox_exec.py @@ -89,10 +89,11 @@ async def test_exec_edge_cases() -> None: assert len(ctrl.commands) == 2 and "rm -f /tmp/.capsem_cmd_" in ctrl.commands[1] ctrl.commands.clear() - env_nonroot = env_for(ctrl, user="developer") + env_nonroot = env_for(ctrl, execution_mode="container", user="developer") assert (await env_nonroot.exec(["id", "-un"])).success assert any( - "su -m developer" in c + not c.startswith("bash -c") + and "su -m developer" in c and 'export USER="$__u" LOGNAME="$__u" HOME="${__h:-/home/$__u}"' in c for c in ctrl.commands ) @@ -147,16 +148,20 @@ async def test_exec_edge_cases() -> None: ) ctrl.commands.clear() - env_uid = env_for(ctrl, user="1000:1000") + env_uid = env_for(ctrl, execution_mode="container", user="1000:1000") assert (await env_uid.exec(["id", "-u"])).success assert any( - "setpriv --reuid=1000 --regid=1000 --clear-groups /bin/bash -c" in c for c in ctrl.commands + not c.startswith("bash -c") + and "setpriv --reuid=1000 --regid=1000 --clear-groups /bin/bash -c" in c + for c in ctrl.commands ) ctrl.commands.clear() - env_bare_uid = env_for(ctrl, user="1000") + env_bare_uid = env_for(ctrl, execution_mode="container", user="1000") assert (await env_bare_uid.exec(["id", "-u"])).success assert any( - "id -un 1000" in c and "setpriv --reuid=1000 --regid=0 --clear-groups /bin/bash -c" in c + not c.startswith("bash -c") + and "id -un 1000" in c + and "setpriv --reuid=1000 --regid=0 --clear-groups /bin/bash -c" in c for c in ctrl.commands ) diff --git a/integrations/inspect-ai/tests/test_transfer.py b/integrations/inspect-ai/tests/test_transfer.py index 90c2be54f..e5d56ff09 100644 --- a/integrations/inspect-ai/tests/test_transfer.py +++ b/integrations/inspect-ai/tests/test_transfer.py @@ -91,6 +91,15 @@ async def fake_exec( assert await sdk_ctrl.download_from_vm(vid, target) == b"direct-bytes" assert await sdk_ctrl.download_from_vm(vid, target, max_bytes=5) == b"direct" assert execs == [] + + writes.clear() + oci_vid = await sdk_ctrl.start_vm(cpu_count=1, ram_gb=1, image="alpine:3.19") + await sdk_ctrl.upload_to_vm(oci_vid, "/workspace/direct/file.bin", b"oci") + assert writes == [("direct/file.bin", b"oci")] + assert await sdk_ctrl.download_from_vm(oci_vid, "/workspace/direct/file.bin") == ( + b"direct-bytes" + ) + assert execs == [] fail_cat = True with pytest.raises(RuntimeError, match="disk full"): await sdk_ctrl.upload_to_vm(vid, "/dest", b"abc") diff --git a/integrations/inspect-ai/uv.lock b/integrations/inspect-ai/uv.lock index 8fdb67369..ffb0232f6 100644 --- a/integrations/inspect-ai/uv.lock +++ b/integrations/inspect-ai/uv.lock @@ -982,6 +982,7 @@ dependencies = [ { name = "capsem" }, { name = "inspect-ai" }, { name = "pydantic" }, + { name = "pyyaml" }, ] [package.dev-dependencies] @@ -1001,6 +1002,7 @@ requires-dist = [ { name = "capsem", editable = "../../sdk/python" }, { name = "inspect-ai", specifier = ">=0.3.263" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] [package.metadata.requires-dev] diff --git a/tests/ironbank/test_sdk_live.py b/tests/ironbank/test_sdk_live.py index 508512dce..824d3cbd7 100644 --- a/tests/ironbank/test_sdk_live.py +++ b/tests/ironbank/test_sdk_live.py @@ -6,6 +6,7 @@ from pathlib import Path import pytest +from helpers.constants import ASSETS_DIR from helpers.gateway import GatewayInstance from helpers.sdk_packages import inspect_ai_gateway, python_gateway, typescript_gateway from helpers.service import ServiceInstance @@ -84,6 +85,7 @@ def test_inspect_ai_live_vm_sandbox_acceptance() -> None: environment = { **{key: value for key, value in os.environ.items() if key != "VIRTUAL_ENV"}, "CAPSEM_HOME": str(service.home_dir), + "CAPSEM_ASSETS_DIR": str(service.assets_dir or ASSETS_DIR), "CAPSEM_GATEWAY_URL": gateway.base_url, "CAPSEM_GATEWAY_TOKEN": gateway.token, } @@ -95,6 +97,7 @@ def test_inspect_ai_live_vm_sandbox_acceptance() -> None: timeout_seconds=240, ) assert "SDK_IMAGE_PACKAGE_ACCEPTANCE_OK" in output + assert "INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK" in output assert "INSPECT_CAPSEM_VM_ACCEPTANCE_OK" in output print(output.strip()) finally: diff --git a/web/docs/src/content/docs/usage/inspect-ai.md b/web/docs/src/content/docs/usage/inspect-ai.md index 698a7eb98..12c688c15 100644 --- a/web/docs/src/content/docs/usage/inspect-ai.md +++ b/web/docs/src/content/docs/usage/inspect-ai.md @@ -19,7 +19,7 @@ gateway (`capsem-service`). ## Install and configure -`inspect-capsem-sandbox` depends on `capsem` (`>=0.7.0`), `inspect-ai`, and `pydantic`: +`inspect-capsem-sandbox` depends on `inspect-ai`, `capsem` (`>=0.7.0`), `pydantic`, and `pyyaml`: ```sh pip install inspect-capsem-sandbox @@ -54,20 +54,40 @@ def my_eval() -> Task: ) ``` -## VM execution +A string sandbox config is also accepted: a `compose.yaml` / `docker-compose.yml` path or a pre-built OCI image reference (`"python:3.12-slim"`). -Commands and file operations execute directly inside an -ephemeral Capsem micro-VM. +## `vm` vs `container` modes + +- **`execution_mode="vm"` (default)**: Commands and file operations execute directly inside an + ephemeral Capsem micro-VM. +- **`execution_mode="container"`**: Commands execute inside a rootless OCI workload container provisioned by `capsem-init` (`Hypervisor.create(image="docker://")`) and entered via `runc exec --cwd / -u 0 workload bash -c …` (selected automatically when `compose_file` or `image` is set). The container image must provide `bash`, coreutils (`timeout`, `stat`, `base64`, `head`, `split`), `tar`/`gzip`, and `setpriv`/`su`/`getent`/`id` when executing as a non-root user. `connection()` (`capsem shell `) opens an interactive shell in the backing Capsem VM. + +### Compose support + +In `container` mode, `inspect-capsem-sandbox` parses single-service `compose.yaml` / `docker-compose.yml` files with `PyYAML`: + +- **Supported service fields**: `image`, `working_dir`, `environment` (mapping or `KEY=VALUE` list, with project `.env` interpolation, Inspect `SAMPLE_METADATA_` synthesis from scalar sample metadata, and operator-owned host environment allowlisting via `CAPSEM_INSPECT_ALLOWED_HOST_ENV`), `user`, `command`, `entrypoint`, `volumes` (host bind-mount sources inside the Compose directory or `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` staged once at sample start as one-way, root-owned `0:0` copies capped at 256 MiB, preserving file modes), `healthcheck` (polled via `sh -c` with default `retries=3`, `interval` sleep capped at 2 s), `mem_limit` / `deploy.resources.limits.memory` (mapped to VM `ram_gb`), and `cpus` / `deploy.resources.limits.cpus` (mapped to VM `cpu_count`). +- **Host environment and path isolation**: Host `os.environ` is default-deny in Compose interpolation and bare `environment` keys unless allowlisted by the operator via `CAPSEM_INSPECT_ALLOWED_HOST_ENV` (explicit variable names or literal-prefix patterns; wildcard-only and character-class patterns are rejected), optionally narrowed by `CapsemSandboxConfig.allowed_host_env`. Host bind-mount `volumes` are restricted to the resolved `compose.yaml` directory (`os.path.realpath` containment) or `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` (optionally narrowed by `CapsemSandboxConfig.allowed_host_paths`). +- **Unsupported**: `Dockerfile` / `build` sections, Compose files without a non-empty `services:` mapping, multi-service `services:` (>1 service), `depends_on`, `env_file`, `ports`, non-default `network_mode` (including `none`, `host`, and `service:`), `internal: true` networks, and privilege/namespace keys (`privileged`, `cap_add`, `cap_drop`, `devices`, `security_opt`, `sysctls`, `pid`, `ipc`, `uts`, `cgroup`) raise `ValueError`. Other unrecognized service keys log a `WARNING` and are ignored (`x-*` extension keys are ignored silently). `x-inspect_k8s_sandbox.allow_domains`, `template`, `allow_domains`, and `host_workspace_dir` raise `NotImplementedError`. ## Configuration reference (`CapsemSandboxConfig`) | Field | Type | Default | Description | | --- | --- | --- | --- | +| `execution_mode` | `"vm" \| "container"` | `"vm"` | Execute directly in the Capsem VM or inside an OCI workload container | +| `image` | `str \| None` | `None` | Pre-built OCI container image admitted in `~/.capsem/settings.toml` when `execution_mode="container"` | | `cpu_count` | `int` | `4` | Virtual CPU count for the Capsem VM | | `ram_gb` | `int` | `8` | Memory allocation in GiB for the Capsem VM | -| `working_dir` | `str` | `"/workspace"` | Default working directory inside the guest VM | +| `working_dir` | `str \| None` | `None` (`"/workspace"` in `vm`; image `WORKDIR` or `"/"` in `container`) | Working directory inside the guest VM or container | +| `compose_file` | `str \| None` | `None` | Path to a single-service `compose.yaml` / `docker-compose.yml` file | | `environment` | `dict[str, str]` | `{}` | Default environment variables passed to the VM session | +| `command` | `tuple[str, ...] \| str \| None` | `None` | Override container command | +| `volumes` | `tuple[str, ...]` | `()` | Host bind-mount specifications (`host_path:container_path[:mode]`) | +| `healthcheck` | `dict[str, Any] \| None` | `None` | Container healthcheck readiness polling configuration | +| `mem_limit` | `str \| None` | `None` | Container memory limit (for example `"2g"`), mapped to VM `ram_gb` | | `user` | `str \| None` | `None` | Default execution user (`uid`, `uid:gid`, or username) | +| `allowed_host_env` | `tuple[str, ...]` | `()` | Optional task-level subset narrowing `CAPSEM_INSPECT_ALLOWED_HOST_ENV` | +| `allowed_host_paths` | `tuple[str, ...]` | `()` | Optional task-level subset narrowing `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` | ## Environment variables @@ -83,6 +103,8 @@ Gateway discovery follows the same precedence as the `capsem` CLI: `labels={"managed-by": "inspect-capsem"}`; setting `CAPSEM_VM_PREFIX=` normalizes `` and attaches `inspect-capsem-prefix: ` so leftover sweeps and `inspect sandbox cleanup capsem` only reap VMs matching the active prefix. +- `CAPSEM_INSPECT_ALLOWED_HOST_ENV`: Operator-owned comma-separated list of host environment variable names or literal-prefix patterns permitted in Compose interpolation and bare `environment` entries. +- `CAPSEM_INSPECT_ALLOWED_HOST_PATHS`: Operator-owned list of host directory or file roots permitted for bind-mount sources outside the Compose directory. - `INSPECT_CAPSEM_SANDBOX_TOOLS_PATH`: Optional host path override for the `inspect-sandbox-tools` binary baked into `/var/tmp/sandbox-services/inspect-sandbox-tools`. @@ -118,4 +140,3 @@ Gateway discovery follows the same precedence as the `capsem` CLI: `inspect sandbox cleanup capsem` to delete any remaining VMs carrying the `managed-by=inspect-capsem` label (and matching `inspect-capsem-prefix` when `CAPSEM_VM_PREFIX` is set). -