diff --git a/CHANGELOG.md b/CHANGELOG.md index 64626be7b..84f458d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1271,7 +1271,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Inspect AI](https://inspect.aisi.org.uk/) `SandboxEnvironment` registered 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_*` + with single-service Docker Compose support, evaluator-granted `Dockerfile` + and Compose `build:` image builds served via a loopback HTTPS OCI v2 + registry with `[images]` `ca_pem` and `admit`, `SAMPLE_METADATA_*` interpolation, and operator-owned host environment and bind-mount allowlists, backed by the Capsem Python gateway SDK (`capsem>=0.7.0`). diff --git a/integrations/inspect-ai/inspect_capsem/__init__.py b/integrations/inspect-ai/inspect_capsem/__init__.py index aadb4aa38..9a65c695f 100644 --- a/integrations/inspect-ai/inspect_capsem/__init__.py +++ b/integrations/inspect-ai/inspect_capsem/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from inspect_capsem.config import CapsemSandboxConfig from inspect_capsem.sandbox import CapsemSandboxEnvironment @@ -9,3 +11,11 @@ "CapsemSandboxConfig", "CapsemSandboxEnvironment", ] + + +def __getattr__(name: str) -> Any: + if name == "HostBuildGrant": + from inspect_capsem.containers.build_grant import HostBuildGrant + + return HostBuildGrant + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/integrations/inspect-ai/inspect_capsem/_compose.py b/integrations/inspect-ai/inspect_capsem/_compose.py index fe1808cd0..ed103b1d8 100644 --- a/integrations/inspect-ai/inspect_capsem/_compose.py +++ b/integrations/inspect-ai/inspect_capsem/_compose.py @@ -50,6 +50,7 @@ def _extract(meta: Mapping[str, Any] | None) -> dict[str, Any]: allowed_host_env=cfg.allowed_host_env, allowed_host_paths=cfg.allowed_host_paths, sample_metadata=meta, + host_build=cfg.host_build, ) overrides = _extract(sample_metadata) @@ -92,10 +93,7 @@ def coerce_config( 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", dockerfile=s) return CapsemSandboxConfig(execution_mode="container", image=s) if isinstance(config, ComposeConfig): from .containers import extract_capsem_compose_fields diff --git a/integrations/inspect-ai/inspect_capsem/_controller.py b/integrations/inspect-ai/inspect_capsem/_controller.py index 874718562..6cdad4183 100644 --- a/integrations/inspect-ai/inspect_capsem/_controller.py +++ b/integrations/inspect-ai/inspect_capsem/_controller.py @@ -71,14 +71,6 @@ async def download_from_vm( async def close(self) -> None: ... -def is_root_user_spec(user: str | None) -> bool: - """Return True when `user` is unset, empty, or resolves to root (`root`, `0`, `0:0`).""" - if user is None: - return True - u, _, g = user.strip().partition(":") - 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 @@ -182,6 +174,18 @@ async def start_vm( except CreateTimeoutError as exc: await self._cleanup_failed_create(exc) raise + except HttpError as exc: + if registry_ca_pem and exc.status in (400, 403): + auth = (norm_image or "").removeprefix("docker://").partition("/")[ + 0 + ] or "127.0.0.1:5055" + raise RuntimeError( + f"capsem-service rejected loopback build registry image {norm_image!r} " + f"({exc}). To enable Compose 'build:' / Dockerfile sandboxes, add " + f'sources = ["{auth}"] and admit = ["{auth}/inspect-capsem/build"] ' + "under [images] in settings.toml." + ) from exc + raise vm_id = str(session.id) self._sessions[vm_id] = session if norm_image: diff --git a/integrations/inspect-ai/inspect_capsem/_exec.py b/integrations/inspect-ai/inspect_capsem/_exec.py index 2bb996999..52abf319f 100644 --- a/integrations/inspect-ai/inspect_capsem/_exec.py +++ b/integrations/inspect-ai/inspect_capsem/_exec.py @@ -13,8 +13,8 @@ from capsem.execution import EXEC_TIMEOUT_CEILING_SECS from inspect_ai.util import ExecResult, OutputLimitExceededError, SandboxEnvironmentLimits -from inspect_capsem._controller import is_root_user_spec from inspect_capsem._files import _resolve_guest_path +from inspect_capsem._users import is_root_user_spec if TYPE_CHECKING: from inspect_capsem._controller import CapsemController diff --git a/integrations/inspect-ai/inspect_capsem/_lifecycle.py b/integrations/inspect-ai/inspect_capsem/_lifecycle.py index 8171e3e16..306a26840 100644 --- a/integrations/inspect-ai/inspect_capsem/_lifecycle.py +++ b/integrations/inspect-ai/inspect_capsem/_lifecycle.py @@ -75,7 +75,13 @@ async def _init_sample_vm( if task_name: sample_labels[_TASK_LABEL] = task_name[:255] oci_image = cfg.image if cfg.execution_mode == "container" else None + ca_pem: str | None = None + if cfg.execution_mode == "container" and cfg.build: + from inspect_capsem.containers.image_build import build_and_stage_oci_image + + oci_image, ca_pem = await build_and_stage_oci_image(cfg.build) oci_cmd = _oci_create_command(cfg) if cfg.execution_mode == "container" else None + extra_kw = {"registry_ca_pem": ca_pem} if ca_pem else {} start_task = asyncio.ensure_future( controller.start_vm( cpu_count=cfg.cpu_count, @@ -84,6 +90,7 @@ async def _init_sample_vm( command=oci_cmd, env=dict(cfg.environment) if cfg.environment else None, labels=sample_labels, + **extra_kw, ) ) try: diff --git a/integrations/inspect-ai/inspect_capsem/_users.py b/integrations/inspect-ai/inspect_capsem/_users.py new file mode 100644 index 000000000..d43113387 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/_users.py @@ -0,0 +1,14 @@ +"""Shared user-specification classification helpers for `inspect-capsem`.""" + +from __future__ import annotations + + +def is_root_user_spec(user: str | None) -> bool: + """Return True when `user` is unset, empty, or resolves to root (`root`, `0`, `0:0`).""" + if user is None: + return True + u, _, g = user.strip().partition(":") + return u.strip().lower() in ("", "root", "0") and g.strip().lower() in ("", "root", "0") + + +__all__ = ["is_root_user_spec"] diff --git a/integrations/inspect-ai/inspect_capsem/config.py b/integrations/inspect-ai/inspect_capsem/config.py index 61888ecc1..84e601cf0 100644 --- a/integrations/inspect-ai/inspect_capsem/config.py +++ b/integrations/inspect-ai/inspect_capsem/config.py @@ -10,11 +10,13 @@ from pydantic import BaseModel, Field, model_validator if TYPE_CHECKING: - from .containers import ContainerSpec + from .containers import ContainerSpec, HostBuildGrant +else: + HostBuildGrant = Any logger = logging.getLogger(__name__) _REJECTED_DOCKERFILE_KEYS = frozenset( - {"dockerfile", "build", "build_context", "build_args", "build_target", "dockerfile_inline"} + {"build_context", "build_args", "build_target", "dockerfile_inline"} ) @@ -59,8 +61,8 @@ def _reject_unsupported_knobs(cls, data: Any) -> Any: 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." + f"Config field {bad_key!r} is not supported on CapsemSandboxConfig; " + "pass 'build' or 'dockerfile' or 'compose_file' instead." ) out = dict(data) out.pop("ports", None) @@ -70,15 +72,36 @@ def _reject_unsupported_knobs(cls, data: Any) -> Any: "Ignoring 'init=True' on CapsemSandboxConfig: Capsem OCI-workload mode " "supervises the container process directly." ) + if out.get("host_build") is not None: + from .containers.build_grant import HostBuildGrant + + grant = HostBuildGrant.from_value(out["host_build"]) + out["host_build"] = ( + out["host_build"] + if isinstance(out["host_build"], bool) + else (grant.to_dict() if grant is not None else None) + ) 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 + if out.get("build") is not None or out.get("dockerfile") is not None: + from .containers.build_grant import resolve_direct_host_build + + out["build"] = resolve_direct_host_build( + {"build": out.get("build"), "dockerfile": out.get("dockerfile")}, + allowed_host_paths=tuple(out.get("allowed_host_paths") or ()), + allowed_host_env=tuple(out.get("allowed_host_env") or ()), + host_build=out.get("host_build"), + ) + if "execution_mode" not in out and ( + out.get("build") is not None + or any( + isinstance(out.get(k), str) and out[k].strip() + for k in ("compose_file", "image", "dockerfile") + ) + ): + out["execution_mode"] = "container" return out return data @@ -88,10 +111,12 @@ def _validate_container_mode_source(self) -> CapsemSandboxConfig: self.execution_mode == "container" and not (self.image and self.image.strip()) and not (self.compose_file and self.compose_file.strip()) + and not (self.dockerfile and self.dockerfile.strip()) + and not self.build ): raise ValueError( "execution_mode='container' requires an explicit OCI image reference " - "(set 'image' or 'compose_file')." + "(set 'image', 'compose_file', 'dockerfile', or 'build')." ) return self @@ -101,6 +126,9 @@ def _validate_container_mode_source(self) -> CapsemSandboxConfig: ram_gb: int = Field(default=8) working_dir: str | None = Field(default=None) compose_file: str | None = None + dockerfile: str | None = None + build: dict[str, Any] | None = None + host_build: HostBuildGrant | bool | None = None environment: dict[str, str] = Field(default_factory=dict) command: tuple[str, ...] | str | None = None volumes: tuple[str, ...] = () @@ -117,10 +145,10 @@ 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(): + if not (self.image and self.image.strip()) and not self.build: raise ValueError( "execution_mode='container' requires an explicit OCI image reference " - "(set 'image' or a Compose service 'image')." + "(set 'image', 'build', 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(): @@ -138,4 +166,5 @@ def to_container_spec(self) -> ContainerSpec: mem_limit=self.mem_limit, user=self.user, allowed_host_paths=tuple(eff_paths), + build=dict(self.build) if self.build is not None else None, ) diff --git a/integrations/inspect-ai/inspect_capsem/containers/__init__.py b/integrations/inspect-ai/inspect_capsem/containers/__init__.py index fe9d865a6..988241833 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/__init__.py +++ b/integrations/inspect-ai/inspect_capsem/containers/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .build_grant import HostBuildGrant from .compose import extract_compose_fields, parse_compose_yaml_file, parse_host_compose_yaml_file from .compose_fields import ( extract_capsem_compose_fields, @@ -17,6 +18,7 @@ "ContainerCommandResult", "ContainerController", "ContainerSpec", + "HostBuildGrant", "extract_capsem_compose_fields", "extract_compose_fields", "normalize_volumes", diff --git a/integrations/inspect-ai/inspect_capsem/containers/build_ca.py b/integrations/inspect-ai/inspect_capsem/containers/build_ca.py new file mode 100644 index 000000000..02f859d10 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/build_ca.py @@ -0,0 +1,145 @@ +"""Host-build CA grant resolution and context staging.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .build_context import _is_within +from .compose_fields import _realpath +from .dockerfile_ca import patch_dockerfile_for_capsem_ca + +CAPSEM_INSPECT_BUILD_CA_PEM_FILE_VAR = "CAPSEM_INSPECT_BUILD_CA_PEM_FILE" +CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE_VAR = "CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE" +_BUILDKIT_FALSE_VALUES = frozenset({"0", "false", "no", "off"}) + + +def _resolve_docker_config_dir(operator_roots: tuple[Path, ...]) -> str | None: + env_dcfg = os.environ.get("CAPSEM_INSPECT_BUILD_DOCKER_CONFIG", "").strip() + if not env_dcfg: + return None + env_real = _realpath(env_dcfg) + if not env_real.is_dir(): + raise ValueError(f"CAPSEM_INSPECT_BUILD_DOCKER_CONFIG directory not found: {env_dcfg}") + if not any(_is_within(env_real, r) for r in operator_roots): + raise ValueError( + f"CAPSEM_INSPECT_BUILD_DOCKER_CONFIG {str(env_real)!r} is not within " + "CAPSEM_INSPECT_ALLOWED_HOST_PATHS" + ) + return str(env_real) + + +def _validate_ca_file(raw_path: str, var_name: str, operator_roots: tuple[Path, ...]) -> Path: + real = Path(os.path.realpath(Path(raw_path).expanduser())) + if not real.is_file(): + raise ValueError(f"{var_name} file not found: {raw_path}") + if not any(real == r or real.is_relative_to(r) for r in operator_roots): + raise ValueError( + f"{var_name} {str(real)!r} is not within CAPSEM_INSPECT_ALLOWED_HOST_PATHS" + ) + if "-----BEGIN CERTIFICATE-----" not in real.read_text(encoding="utf-8", errors="replace"): + raise ValueError(f"{var_name} {str(real)!r} does not contain a PEM certificate") + return real + + +def resolve_build_ca_files( + grant: Any, network: str, operator_roots: tuple[Path, ...] +) -> tuple[str | None, str | None]: + """Validate `CAPSEM_INSPECT_BUILD_CA_PEM_FILE` and `..._BUNDLE_FILE` (or opt out via `ca_pem=False`).""" + env_ca = os.environ.get("CAPSEM_INSPECT_BUILD_CA_PEM_FILE", "").strip() + env_bundle = os.environ.get("CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE", "").strip() + if not env_ca: + return None, None + ca_real = _validate_ca_file(env_ca, CAPSEM_INSPECT_BUILD_CA_PEM_FILE_VAR, operator_roots) + if grant is not None and getattr(grant, "ca_pem", True) is False: + return None, None + if env_bundle: + b_real = _validate_ca_file( + env_bundle, CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE_VAR, operator_roots + ) + return str(ca_real), str(b_real) + if network != "none": + raise ValueError( + f"CA patching with network={network!r} requires " + f"{CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE_VAR} (full root + Capsem CA bundle)" + ) + return str(ca_real), str(ca_real) + + +def compute_ca_fingerprint(spec: Mapping[str, Any]) -> str: + """Compute SHA-256 fingerprint over the granted CA PEM and full bundle files.""" + if not (ca_file := spec.get("ca_pem_file")): + return "" + ca_bytes = Path(str(ca_file)).read_bytes() + bundle_bytes = Path(str(spec.get("ca_bundle_file") or ca_file)).read_bytes() + return hashlib.sha256(ca_bytes + b"\0" + bundle_bytes).hexdigest() + + +def stage_capsem_ca_in_context(src_ctx: Path, dst_ctx: Path, spec: Mapping[str, Any]) -> None: + """Copy `src_ctx` to `dst_ctx` and stage `.capsem-ca.crt` + `.capsem-ca-bundle.crt`.""" + shutil.copytree(src_ctx, dst_ctx, symlinks=True) + ca_text = Path(str(spec["ca_pem_file"])).read_text(encoding="utf-8") + bundle_text = Path(str(spec.get("ca_bundle_file") or spec["ca_pem_file"])).read_text( + encoding="utf-8" + ) + if ca_text.strip() and ca_text.strip() not in bundle_text: + bundle_text = f"{bundle_text.rstrip()}\n{ca_text.strip()}\n" + (dst_ctx / ".capsem-ca.crt").write_text(ca_text, encoding="utf-8") + (dst_ctx / ".capsem-ca-bundle.crt").write_text(bundle_text, encoding="utf-8") + if (di := dst_ctx / ".dockerignore").is_file(): + existing = di.read_text(encoding="utf-8", errors="replace") + di.write_text(f"{existing}\n!.capsem-ca.crt\n!.capsem-ca-bundle.crt\n", encoding="utf-8") + + +def prepare_build_inputs( + spec: Mapping[str, Any], + tmp_root: Path, +) -> tuple[Path, Path]: + """Verify BuildKit is not disabled and stage CA files when `ca_pem_file` is configured.""" + if (os.getenv("DOCKER_BUILDKIT") or "").strip().lower() in _BUILDKIT_FALSE_VALUES: + raise RuntimeError( + "Host-side image builds require Docker BuildKit (DOCKER_BUILDKIT must not be disabled)." + ) + ctx_dir = Path(str(spec["context"])) + df_path = Path(str(spec["dockerfile"])) + if spec.get("ca_pem_file"): + df_text = df_path.read_text(encoding="utf-8", errors="replace") + staged_ctx = tmp_root / "staged-context" + stage_capsem_ca_in_context(ctx_dir, staged_ctx, spec) + ctx_dir = staged_ctx + df_text = patch_dockerfile_for_capsem_ca(df_text) + df_path = tmp_root / "Dockerfile.capsem" + df_path.write_text(df_text, encoding="utf-8") + return ctx_dir, df_path + + +_RESOLVED_BUILD_KEYS = frozenset( + { + "context", + "dockerfile", + "args", + "target", + "network", + "docker_config_dir", + "ca_pem_file", + "ca_bundle_file", + } +) + + +def _is_resolved_build_spec(val: Mapping[str, Any]) -> bool: + return ( + set(val.keys()) == _RESOLVED_BUILD_KEYS + and isinstance(val.get("context"), str) + and isinstance(val.get("dockerfile"), str) + and isinstance(val.get("args"), Mapping) + and val.get("network") in ("none", "default") + and all( + val.get(k) is None or isinstance(val.get(k), str) + for k in ("target", "docker_config_dir", "ca_pem_file", "ca_bundle_file") + ) + ) diff --git a/integrations/inspect-ai/inspect_capsem/containers/build_context.py b/integrations/inspect-ai/inspect_capsem/containers/build_context.py new file mode 100644 index 000000000..fb6ed6b2e --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/build_context.py @@ -0,0 +1,251 @@ +"""Build context `.dockerignore` filtering, symlink containment, and deterministic cache keys.""" + +from __future__ import annotations + +import fnmatch +import hashlib +import json +import os +import platform as py_platform +import stat +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + + +def _is_within(child: Path, parent: Path) -> bool: + try: + child.relative_to(parent) + return True + except ValueError: + return False + + +def default_linux_platform() -> str: + """Return the target Linux OCI platform matching the current host architecture.""" + override = os.environ.get("CAPSEM_INSPECT_BUILD_PLATFORM", "").strip() + if override: + return override + arch = py_platform.machine().lower() + return "linux/arm64" if arch in ("aarch64", "arm64") else "linux/amd64" + + +def _parse_dockerignore(context_dir: Path) -> list[tuple[bool, str]]: + ignore_file = context_dir / ".dockerignore" + if ignore_file.is_symlink() and not _is_within( + Path(os.path.realpath(ignore_file)), context_dir + ): + raise ValueError(f".dockerignore symlink escapes build context {context_dir}") + if not ignore_file.is_file(): + return [] + rules: list[tuple[bool, str]] = [] + for raw_line in ignore_file.read_text(encoding="utf-8", errors="replace").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + neg = line.startswith("!") + pat = (line[1:].strip() if neg else line).replace("\\", "/").strip("/") + while pat.startswith("./"): + pat = pat[2:].strip("/") + if pat: + rules.append((neg, pat)) + return rules + + +def _matches_dockerignore_pattern(rel_posix: str, pat: str) -> bool: + parts = rel_posix.split("/") + for i in range(1, len(parts) + 1): + prefix = "/".join(parts[:i]) + if fnmatch.fnmatchcase(prefix, pat): + return True + if pat.endswith("/**") and fnmatch.fnmatchcase(prefix, pat[:-3]): + return True + return False + + +def _is_dockerignored(rel_posix: str, rules: list[tuple[bool, str]]) -> bool: + ignored = False + for neg, pat in rules: + if _matches_dockerignore_pattern(rel_posix, pat): + ignored = not neg + return ignored + + +def iter_context_files( + context_dir: Path, *, include_ignored_regular: bool = False +) -> list[tuple[str, Path]]: + """Return sorted `(rel_posix, path)` for context entries, enforcing symlink containment.""" + ctx_real = Path(os.path.realpath(context_dir)) + if not ctx_real.is_dir(): + raise FileNotFoundError(f"Build context directory not found: {context_dir}") + rules = _parse_dockerignore(ctx_real) + has_negation = any(neg for neg, _ in rules) + entries: list[tuple[str, Path]] = [] + + for root, dirnames, filenames in os.walk(ctx_real, topdown=True, followlinks=False): + root_path = Path(root) + rel_root = root_path.relative_to(ctx_real) + dirnames.sort() + filenames.sort() + + kept_dirs: list[str] = [] + for d in dirnames: + d_path = root_path / d + d_rel = (rel_root / d).as_posix() + ignored_dir = _is_dockerignored(d_rel, rules) + if d_path.is_symlink(): + if not ignored_dir and not _is_within(Path(os.path.realpath(d_path)), ctx_real): + raise ValueError( + f"Build context directory symlink {d_rel!r} escapes context root {ctx_real}" + ) + continue + if ignored_dir and not has_negation and not include_ignored_regular: + continue + kept_dirs.append(d) + dirnames[:] = kept_dirs + + for fname in filenames: + fpath = root_path / fname + f_rel = (rel_root / fname).as_posix() + ignored_file = _is_dockerignored(f_rel, rules) + st_mode = fpath.lstat().st_mode + if stat.S_ISLNK(st_mode): + if ignored_file: + continue + if not _is_within(Path(os.path.realpath(fpath)), ctx_real): + raise ValueError( + f"Build context symlink {f_rel!r} escapes context root {ctx_real}" + ) + entries.append((f_rel, fpath)) + elif stat.S_ISREG(st_mode) and (include_ignored_regular or not ignored_file): + entries.append((f_rel, fpath)) + + return sorted(entries, key=lambda item: item[0]) + + +def prepare_compose_build_service( + svc: Mapping[str, Any], *, direct_config: bool = False +) -> tuple[dict[str, Any], str | None]: + """Normalize service `build` / `dockerfile` before `compose_service.extract_compose_fields`.""" + raw_build, raw_df = svc.get("build"), svc.get("dockerfile") + if raw_build is None and raw_df is None: + return dict(svc), None + stanza = "build" if raw_build is not None else "dockerfile" + out = {k: v for k, v in svc.items() if k != "dockerfile"} + if raw_df is not None: + if not isinstance(raw_df, str) or not raw_df.strip(): + raise ValueError("Compose 'dockerfile' must be a non-empty path string") + df_s, df_p = raw_df.strip(), Path(raw_df.strip()) + if raw_build is None: + out["build"] = ( + {"context": str(df_p.parent) or ".", "dockerfile": df_p.name} + if direct_config + else {"context": ".", "dockerfile": df_s} + ) + elif isinstance(raw_build, str) and raw_build.strip(): + out["build"] = {"context": raw_build.strip(), "dockerfile": df_s} + elif isinstance(raw_build, Mapping): + out["build"] = {"dockerfile": df_s, **dict(raw_build)} + else: + raise ValueError("Compose 'build' must be a context path string or mapping") + elif isinstance(raw_build, str) and raw_build.strip(): + out["build"] = {"context": raw_build.strip()} + elif isinstance(raw_build, Mapping): + out["build"] = dict(raw_build) + else: + raise ValueError("Compose 'build' must be a non-empty context path string or mapping") + + b_map = out["build"] + for key in ("context", "dockerfile"): + if ( + key in b_map + and b_map[key] is not None + and (not isinstance(b_map[key], str) or not b_map[key].strip()) + ): + raise ValueError(f"Compose 'build.{key}' must be a non-empty path string") + ctx_val = str(b_map.setdefault("context", ".")).strip() + if "://" in ctx_val or ctx_val.startswith("git@"): + raise ValueError(f"Remote build context {ctx_val!r} is not supported by inspect-capsem") + return out, stanza + + +def resolve_context_and_dockerfile( + dockerfile: str, + build_context: str | None, + allowed_roots: Sequence[Path], + *, + stanza: str = "build", +) -> tuple[Path, Path]: + """Validate `build_context` and `dockerfile` realpaths against `allowed_roots`.""" + df_cand = Path(dockerfile).expanduser() + ctx_cand = Path(build_context).expanduser() if build_context is not None else df_cand.parent + ctx_real = Path(os.path.realpath(ctx_cand)) + if not any(_is_within(ctx_real, r) for r in allowed_roots): + raise ValueError( + f"Build context {str(ctx_real)!r} for {stanza!r} is not allowlisted in " + "CAPSEM_INSPECT_ALLOWED_HOST_PATHS / allowed_host_paths / allowed_contexts." + ) + if ctx_real.exists() and not ctx_real.is_dir(): + raise ValueError(f"Build context path is not a directory: {ctx_cand}") + if ctx_real.is_dir(): + iter_context_files(ctx_real) + + if ( + not df_cand.exists() + and df_cand.name == "Dockerfile" + and (ctx_real / "Containerfile").exists() + ): + df_cand = ctx_real / "Containerfile" + df_real = Path(os.path.realpath(df_cand)) + if not any(_is_within(df_real, r) for r in allowed_roots): + raise ValueError( + f"Dockerfile {str(df_real)!r} for {stanza!r} is not allowlisted in " + "CAPSEM_INSPECT_ALLOWED_HOST_PATHS / allowed_host_paths / allowed_contexts." + ) + if _is_within(df_cand.absolute(), ctx_cand.absolute()) and not _is_within(df_real, ctx_real): + raise ValueError(f"Dockerfile {str(df_cand)!r} escapes build context {str(ctx_real)!r}") + if (df_cand.exists() or df_cand.is_symlink()) and not df_real.is_file(): + raise ValueError(f"Dockerfile path is not a regular file: {df_cand}") + return ctx_real, df_real + + +def compute_build_cache_key( + spec: Mapping[str, Any], + platform: str | None = None, + ca_fingerprint: str | None = None, +) -> str: + """Compute a deterministic SHA-256 cache key over the build spec, CA bundle, and context.""" + from .build_ca import compute_ca_fingerprint + + eff_platform = (platform or default_linux_platform()).strip() + ctx_dir = Path(str(spec["context"])) + df_str = spec.get("dockerfile") + if not df_str: + raise FileNotFoundError("Dockerfile path is required in build spec") + df_path = Path(str(df_str)) + if not df_path.is_file(): + raise FileNotFoundError(f"Dockerfile not found: {df_path}") + df_bytes = df_path.read_bytes() + eff_ca_fp = ca_fingerprint if ca_fingerprint is not None else compute_ca_fingerprint(spec) + + h = hashlib.sha256() + header = { + "v": 2, + "platform": eff_platform, + "dockerfile_sha256": hashlib.sha256(df_bytes).hexdigest(), + "target": spec.get("target"), + "args": sorted((str(k), str(v)) for k, v in (spec.get("args") or {}).items()), + "network": spec.get("network", "none"), + "ca_sha256": eff_ca_fp or "", + } + h.update(json.dumps(header, sort_keys=True, separators=(",", ":")).encode()) + for rel_posix, fpath in iter_context_files(ctx_dir, include_ignored_regular=True): + st = fpath.lstat() + if fpath.is_symlink(): + h.update( + f"\0L\0{rel_posix}\0{os.readlink(fpath)}".encode("utf-8", errors="surrogateescape") + ) + else: + fh = hashlib.sha256(fpath.read_bytes()).hexdigest() + h.update(f"\0F\0{rel_posix}\0{oct(st.st_mode & 0o7777)}\0{fh}".encode()) + return h.hexdigest() diff --git a/integrations/inspect-ai/inspect_capsem/containers/build_grant.py b/integrations/inspect-ai/inspect_capsem/containers/build_grant.py new file mode 100644 index 000000000..9a602490c --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/build_grant.py @@ -0,0 +1,221 @@ +"""Evaluator-granted host image build policy (`HostBuildGrant` and env-authority narrowing).""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from .build_ca import ( + _is_resolved_build_spec, + _resolve_docker_config_dir, + resolve_build_ca_files, +) +from .build_context import ( + _is_within, + prepare_compose_build_service, + resolve_context_and_dockerfile, +) +from .compose_fields import CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR, _realpath + +_TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) +_FALSE_VALUES = frozenset({"0", "false", "no", "off"}) +_ALLOWED_BUILD_NETWORKS = frozenset({"none", "default"}) +_GRANT_FIELDS = frozenset({"enabled", "allowed_contexts", "network", "ca_pem"}) + + +@dataclass(frozen=True) +class HostBuildGrant: + """Task-level host-build configuration narrowing the evaluator environment.""" + + enabled: bool = True + allowed_contexts: tuple[str, ...] = () + network: Literal["none", "default"] | None = None + ca_pem: bool = True + + def __post_init__(self) -> None: + clean_ctx = tuple(str(p).strip() for p in self.allowed_contexts if str(p).strip()) + object.__setattr__(self, "allowed_contexts", clean_ctx) + if self.network == "host": + raise ValueError("Host build policy forbids network='host'; allowed: 'none', 'default'") + if self.network is not None and self.network not in _ALLOWED_BUILD_NETWORKS: + raise ValueError(f"Unsupported host build network {self.network!r}") + if not isinstance(self.ca_pem, bool): + raise TypeError(f"HostBuildGrant.ca_pem must be a bool; got {type(self.ca_pem)!r}") + + @classmethod + def from_value(cls, value: Any) -> HostBuildGrant | None: + if value is None or isinstance(value, HostBuildGrant): + return value + if isinstance(value, bool): + return cls(enabled=value) + if isinstance(value, Mapping): + if unknown := sorted({str(k) for k in value} - _GRANT_FIELDS): + raise ValueError(f"Unknown HostBuildGrant fields: {unknown}") + raw_ctx = value.get("allowed_contexts") or () + items = raw_ctx.split(",") if isinstance(raw_ctx, str) else raw_ctx + raw_net = value.get("network") + net: Any = str(raw_net).strip().lower() if raw_net is not None else None + raw_ca = value.get("ca_pem", True) + if not isinstance(raw_ca, bool): + raise TypeError(f"HostBuildGrant.ca_pem must be a bool; got {type(raw_ca)!r}") + return cls( + enabled=bool(value.get("enabled", True)), + allowed_contexts=tuple(str(p).strip() for p in items if str(p).strip()), + network=net, + ca_pem=raw_ca, + ) + raise TypeError(f"Expected HostBuildGrant, mapping, or bool; got {type(value)!r}") + + def to_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "allowed_contexts": list(self.allowed_contexts), + "network": self.network, + "ca_pem": self.ca_pem, + } + + +def is_host_build_enabled(host_build: Any = None) -> bool: + """Return True only when `CAPSEM_INSPECT_HOST_BUILD=1` in env and not disabled by task.""" + env_raw = os.environ.get("CAPSEM_INSPECT_HOST_BUILD", "").strip().lower() + if env_raw and env_raw not in (_TRUE_VALUES | _FALSE_VALUES): + raise ValueError( + f"Invalid CAPSEM_INSPECT_HOST_BUILD={env_raw!r}; expected 1/true or 0/false" + ) + if env_raw not in _TRUE_VALUES: + return False + grant = HostBuildGrant.from_value(host_build) + return not (grant is not None and not grant.enabled) + + +def _resolve_build_network(grant: HostBuildGrant | None) -> str: + env_net = os.environ.get("CAPSEM_INSPECT_BUILD_NETWORK", "").strip().lower() or "none" + if env_net == "host": + raise ValueError("Host build policy forbids network='host' in CAPSEM_INSPECT_BUILD_NETWORK") + if env_net not in _ALLOWED_BUILD_NETWORKS: + raise ValueError(f"Unsupported CAPSEM_INSPECT_BUILD_NETWORK={env_net!r}") + if grant is not None and grant.network is not None: + if grant.network == "default" and env_net == "none": + raise ValueError( + "HostBuildGrant(network='default') exceeds operator ceiling " + "CAPSEM_INSPECT_BUILD_NETWORK='none'" + ) + return grant.network + return env_net + + +def _narrow_roots( + candidates: Sequence[str], ceiling: tuple[Path, ...], label: str +) -> tuple[Path, ...]: + raw_items = tuple(r.strip() for r in candidates if r and r.strip()) + if not raw_items: + return ceiling + if not ceiling: + raise ValueError(f"{label} cannot widen unset CAPSEM_INSPECT_ALLOWED_HOST_PATHS") + narrowed: list[Path] = [] + for raw in raw_items: + p = _realpath(raw) + if not any(_is_within(p, c) for c in ceiling): + raise ValueError( + f"{label} path {str(p)!r} is not allowlisted in CAPSEM_INSPECT_ALLOWED_HOST_PATHS" + ) + narrowed.append(p) + return tuple(narrowed) + + +def _resolve_allowed_build_roots( + allowed_host_paths: Sequence[str], grant: HostBuildGrant | None +) -> tuple[tuple[Path, ...], tuple[Path, ...]]: + raw_op = os.environ.get(CAPSEM_INSPECT_ALLOWED_HOST_PATHS_VAR, "") + op_roots = tuple( + _realpath(s.strip()) for c in raw_op.split(",") for s in c.split(os.pathsep) if s.strip() + ) + eff_roots = _narrow_roots(allowed_host_paths, op_roots, "allowed_host_paths") + if grant and grant.allowed_contexts: + eff_roots = _narrow_roots( + grant.allowed_contexts, eff_roots, "HostBuildGrant.allowed_contexts" + ) + return op_roots, eff_roots + + +def resolve_effective_host_build( + dockerfile: str, + build_context: str | None = None, + *, + build_args: Mapping[str, str] | None = None, + build_target: str | None = None, + stanza: str = "build", + allowed_host_paths: Sequence[str] = (), + host_build: Any = None, +) -> dict[str, Any]: + """Validate evaluator host-build grant, path containment, and return normalized build spec.""" + grant = HostBuildGrant.from_value(host_build) + if not is_host_build_enabled(grant): + raise ValueError( + f"Host-side image build for {stanza!r} is disabled by default; " + "set CAPSEM_INSPECT_HOST_BUILD=1 in the evaluator environment." + ) + operator_roots, allowed_roots = _resolve_allowed_build_roots(allowed_host_paths, grant) + if not allowed_roots: + raise ValueError( + f"Host-side image build for {stanza!r} requires CAPSEM_INSPECT_ALLOWED_HOST_PATHS " + "(and optional allowed_host_paths within it)." + ) + ctx_real, df_real = resolve_context_and_dockerfile( + dockerfile, build_context, allowed_roots, stanza=stanza + ) + net = _resolve_build_network(grant) + ca_file, bundle_file = resolve_build_ca_files(grant, net, operator_roots) + return { + "context": str(ctx_real), + "dockerfile": str(df_real), + "args": {str(k): str(v) for k, v in (build_args or {}).items()}, + "target": build_target, + "network": net, + "docker_config_dir": _resolve_docker_config_dir(operator_roots), + "ca_pem_file": ca_file, + "ca_bundle_file": bundle_file, + } + + +def resolve_direct_host_build( + svc: Mapping[str, Any], + *, + allowed_host_paths: Sequence[str] = (), + allowed_host_env: Sequence[str] = (), + host_build: Any = None, +) -> dict[str, Any] | None: + """Parse and validate direct `CapsemSandboxConfig(build=..., dockerfile=...)`.""" + raw_build = svc.get("build") + if isinstance(raw_build, Mapping) and _is_resolved_build_spec(raw_build): + return resolve_effective_host_build( + str(raw_build["dockerfile"]), + str(raw_build["context"]), + build_args=raw_build["args"], + build_target=raw_build.get("target"), + allowed_host_paths=allowed_host_paths, + host_build=host_build, + ) + svc_clean, stanza = prepare_compose_build_service(svc, direct_config=True) + if stanza is None: + return None + from .compose_inputs import build_host_compose_inputs + from .compose_service import extract_compose_fields + + extracted = extract_compose_fields( + {"services": {"default": svc_clean}}, + base_dir=None, + inputs=build_host_compose_inputs(allowed_host_env=allowed_host_env), + ) + return resolve_effective_host_build( + extracted["dockerfile"], + extracted.get("build_context"), + build_args=extracted.get("build_args"), + build_target=extracted.get("build_target"), + stanza=stanza, + allowed_host_paths=allowed_host_paths, + host_build=host_build, + ) diff --git a/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py b/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py index c196d81d1..0d5b36e6f 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py +++ b/integrations/inspect-ai/inspect_capsem/containers/compose_fields.py @@ -18,28 +18,19 @@ 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, -) +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." @@ -160,7 +151,7 @@ def normalize_volumes( 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." + "use a relative or allowlisted (allowed_host_paths) bind mount." ) return tuple(out) @@ -172,10 +163,15 @@ def extract_capsem_compose_fields( allowed_host_env: Sequence[str] = (), allowed_host_paths: Sequence[str] = (), sample_metadata: Mapping[str, Any] | None = None, + host_build: Any = None, ) -> dict[str, Any]: """Extract and validate Capsem container overrides from a parsed Compose mapping.""" + from .build_grant import prepare_compose_build_service, resolve_effective_host_build + declared_vols = _resolve_declared_volumes(parsed.get("volumes")) - services, svc, svc_name, parsed_for_extract = parsed.get("services"), {}, "default", parsed + services = parsed.get("services") + svc: Mapping[str, Any] = {} + svc_name, parsed_for_extract, stanza = "default", parsed, None if isinstance(services, Mapping) and len(services) == 1: raw_name, raw_svc = next(iter(services.items())) svc_name = str(raw_name) @@ -184,8 +180,9 @@ def extract_capsem_compose_fields( 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} + svc_clean, stanza = prepare_compose_build_service( + {k: v for k, v in svc.items() if k not in ("cpus", "platform")} + ) parsed_for_extract = {**parsed, "services": {svc_name: svc_clean}} inputs = build_host_compose_inputs( base_dir=base_dir, @@ -194,8 +191,7 @@ def extract_capsem_compose_fields( 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") + net_mode, explicit_net = out.pop("network_mode", None), svc.get("network_mode") if isinstance(explicit_net, str) and (cleaned_mode := explicit_net.strip()): if cleaned_mode not in ("bridge", "default"): raise ValueError( @@ -217,8 +213,11 @@ def extract_capsem_compose_fields( "the container process directly.", svc_name, ) - cmd = _combine_entrypoint_and_command(out.pop("entrypoint", None), out.pop("command", None)) - if cmd is not None: + if ( + cmd := _combine_entrypoint_and_command( + out.pop("entrypoint", None), out.pop("command", None) + ) + ) is not None: out["command"] = cmd if "mem_limit" in out: out["ram_gb"] = parse_memory_to_ram_gb(out["mem_limit"]) @@ -234,4 +233,14 @@ def extract_capsem_compose_fields( out["volumes"] = normalize_volumes( raw_vols, base_dir, allowed_host_paths, declared_volumes=declared_vols ) + if stanza is not None and "dockerfile" in out: + out["build"] = resolve_effective_host_build( + dockerfile=out.pop("dockerfile"), + build_context=out.pop("build_context", None), + build_args=out.pop("build_args", None), + build_target=out.pop("build_target", None), + stanza=stanza, + allowed_host_paths=allowed_host_paths, + host_build=host_build, + ) return out diff --git a/integrations/inspect-ai/inspect_capsem/containers/dockerfile.py b/integrations/inspect-ai/inspect_capsem/containers/dockerfile.py index d213cf5c6..639c79028 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/dockerfile.py +++ b/integrations/inspect-ai/inspect_capsem/containers/dockerfile.py @@ -4,31 +4,54 @@ File loading, grants, build execution and ownership belong to their controllers. """ -from .dockerfile_ca import _CA_ENV as _CA_ENV -from .dockerfile_ca import _CA_READY as _CA_READY -from .dockerfile_ca import _CA_STAGE_LINES as _CA_STAGE_LINES -from .dockerfile_ca import _CA_UPDATE_LINE as _CA_UPDATE_LINE -from .dockerfile_ca import _IMAGE_CA as _IMAGE_CA -from .dockerfile_ca import _IMAGE_CA_BUNDLE as _IMAGE_CA_BUNDLE -from .dockerfile_ca import _extract_ca_fingerprint as _extract_ca_fingerprint -from .dockerfile_ca import _stage_has_any_run as _stage_has_any_run -from .dockerfile_ca import _stage_runs_shell as _stage_runs_shell -from .dockerfile_ca import is_root_user_spec as is_root_user_spec -from .dockerfile_ca import patch_dockerfile_for_capsem_ca as patch_dockerfile_for_capsem_ca -from .dockerfile_heredoc import _escape_unquoted_heredoc_line as _escape_unquoted_heredoc_line -from .dockerfile_heredoc import _format_printf_heredoc_cmd as _format_printf_heredoc_cmd -from .dockerfile_heredoc import ( - _lower_single_instruction_heredocs as _lower_single_instruction_heredocs, +from .dockerfile_ca import ( + _CA_ENV, + _CA_READY, + _CA_STAGE_LINES, + _CA_UPDATE_LINE, + _IMAGE_CA, + _IMAGE_CA_BUNDLE, + _extract_ca_fingerprint, + _stage_has_any_run, + _stage_runs_shell, + is_root_user_spec, + patch_dockerfile_for_capsem_ca, ) -from .dockerfile_heredoc import _shell_single_quote as _shell_single_quote -from .dockerfile_heredoc import lower_dockerfile_heredocs as lower_dockerfile_heredocs -from .dockerfile_metadata import _DOCKERFILE_VAR_RE as _DOCKERFILE_VAR_RE -from .dockerfile_metadata import _expand_dockerfile_vars as _expand_dockerfile_vars -from .dockerfile_metadata import _extract_dockerfile_metadata as _extract_dockerfile_metadata -from .dockerfile_metadata import _strip_optional_quotes as _strip_optional_quotes -from .dockerfile_metadata import _update_dockerfile_vars as _update_dockerfile_vars -from .dockerfile_scan import _ESCAPE_DIRECTIVE_RE as _ESCAPE_DIRECTIVE_RE -from .dockerfile_scan import _HEREDOC_TOKEN_RE as _HEREDOC_TOKEN_RE -from .dockerfile_scan import _dockerfile_instructions as _dockerfile_instructions -from .dockerfile_scan import _find_heredoc_openers_detailed as _find_heredoc_openers_detailed -from .dockerfile_scan import _scan_dockerfile_instructions as _scan_dockerfile_instructions +from .dockerfile_metadata import ( + _DOCKERFILE_VAR_RE, + _expand_dockerfile_vars, + _extract_dockerfile_metadata, + _strip_optional_quotes, + _update_dockerfile_vars, +) +from .dockerfile_scan import ( + _ESCAPE_DIRECTIVE_RE, + _HEREDOC_TOKEN_RE, + _dockerfile_instructions, + _find_heredoc_openers_detailed, + _scan_dockerfile_instructions, +) + +__all__ = [ + "_CA_ENV", + "_CA_READY", + "_CA_STAGE_LINES", + "_CA_UPDATE_LINE", + "_DOCKERFILE_VAR_RE", + "_ESCAPE_DIRECTIVE_RE", + "_HEREDOC_TOKEN_RE", + "_IMAGE_CA", + "_IMAGE_CA_BUNDLE", + "_dockerfile_instructions", + "_expand_dockerfile_vars", + "_extract_ca_fingerprint", + "_extract_dockerfile_metadata", + "_find_heredoc_openers_detailed", + "_scan_dockerfile_instructions", + "_stage_has_any_run", + "_stage_runs_shell", + "_strip_optional_quotes", + "_update_dockerfile_vars", + "is_root_user_spec", + "patch_dockerfile_for_capsem_ca", +] diff --git a/integrations/inspect-ai/inspect_capsem/containers/dockerfile_ca.py b/integrations/inspect-ai/inspect_capsem/containers/dockerfile_ca.py index 69056b89d..a69316001 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/dockerfile_ca.py +++ b/integrations/inspect-ai/inspect_capsem/containers/dockerfile_ca.py @@ -1,7 +1,27 @@ -"""Pure Dockerfile support from Pierre Tholoniat's bb61fc82d integration.""" +"""Pure Dockerfile support from Pierre Tholoniat's bb61fc82d integration. + +In Capsem 0.7, the Capsem MITM CA is owned by the operator/evaluator on the host +(e.g. `~/.capsem/ca.crt` or the profile CA; no SDK route exposes it). When host +image builds opt into CA patching via `CAPSEM_INSPECT_BUILD_CA_PEM_FILE`: +- `.capsem-ca.crt` (`/usr/local/share/ca-certificates/capsem-ca.crt`) holds the Capsem + CA certificate and is registered via `update-ca-certificates` and NSS `certutil`. +- `.capsem-ca-bundle.crt` (`/usr/local/share/capsem/ca-bundle.crt`) holds the full + root CA bundle (`CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE` + Capsem CA, required when + `network != "none"`) so host `docker build` `RUN` steps verify public TLS hosts. +- Build-time `ENV` sets `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`, + `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO` to `/usr/local/share/capsem/ca-bundle.crt`, + `NODE_EXTRA_CA_CERTS` to `/usr/local/share/ca-certificates/capsem-ca.crt`, and + `UV_NATIVE_TLS=1`. At runtime inside the VM, `guest/artifacts/container/launch.py` + bind-mounts `/etc/ssl/certs/ca-certificates.crt` read-only and overrides + `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, and `NODE_EXTRA_CA_CERTS`. +- `is_root_user_spec` (shared via `inspect_capsem._users`) checks both user and group + parts so `USER root:` (e.g. `root:1000`) is treated as non-root. +""" from __future__ import annotations +from inspect_capsem._users import is_root_user_spec + from .dockerfile_scan import _dockerfile_instructions _CA_READY = "CAPSEM_CA_READY" @@ -26,10 +46,19 @@ "ENV " + " ".join(f"{k}={v}" for k, v in _CA_ENV.items()), ) +_CA_NSS_SCRIPT = ( + "if command -v certutil >/dev/null 2>&1; then " + 'db="sql:${HOME:-/root}/.pki/nssdb"; mkdir -p "${HOME:-/root}/.pki/nssdb"; ' + 'certutil -d "$db" -L >/dev/null 2>&1 || certutil -d "$db" -N --empty-password; ' + f'certutil -d "$db" -A -n capsem-ca -t C,, -i {_IMAGE_CA}; ' + "fi" +) + _CA_UPDATE_LINE = ( - "RUN command -v update-ca-certificates >/dev/null 2>&1 " + "RUN { command -v update-ca-certificates >/dev/null 2>&1 " '&& { [ "$(id -u)" = 0 ] && update-ca-certificates >/dev/null 2>&1 ' - "|| sudo -n update-ca-certificates >/dev/null 2>&1; } || true" + "|| sudo -n update-ca-certificates >/dev/null 2>&1; } || true; }; " + f"{{ {_CA_NSS_SCRIPT}; }} || true" ) @@ -64,14 +93,6 @@ def _stage_has_any_run(rest: list[tuple[str, str, list[str]]]) -> bool: return False -def is_root_user_spec(user: str | None) -> bool: - """Return True when `user` is unset, empty, or resolves to root (`root`, `0`, `0:0`, `root:root`).""" - if user is None: - return True - head = user.strip().split(":", 1)[0].strip().lower() - return head in ("", "root", "0") - - def patch_dockerfile_for_capsem_ca(text: str) -> str: """Add the Capsem MITM CA to every stage that can make TLS calls during the build. diff --git a/integrations/inspect-ai/inspect_capsem/containers/dockerfile_heredoc.py b/integrations/inspect-ai/inspect_capsem/containers/dockerfile_heredoc.py deleted file mode 100644 index 22cbea1d8..000000000 --- a/integrations/inspect-ai/inspect_capsem/containers/dockerfile_heredoc.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Pure Dockerfile support from Pierre Tholoniat's bb61fc82d integration.""" - -from __future__ import annotations - -import posixpath -import shlex - -from .dockerfile_scan import _scan_dockerfile_instructions - - -def _shell_single_quote(s: str) -> str: - return "'" + s.replace("'", "'\"'\"'") + "'" - - -def _escape_unquoted_heredoc_line(ln: str, *, allow_cmd_subst: bool = True) -> str: - """Translate one line of an unquoted `< str: - """Render `body_lines` as a single-line POSIX `printf` command without physical newlines.""" - if not body_lines: - return "printf ''" - if is_quoted or not any(("$" in ln or "`" in ln or "\\" in ln) for ln in body_lines): - args = " ".join(_shell_single_quote(ln) for ln in body_lines) - return f"printf '%s\\n' {args}" - dq_parts = [ - f'"{_escape_unquoted_heredoc_line(ln, allow_cmd_subst=allow_cmd_subst)}"' - for ln in body_lines - ] - return f"printf '%s\\n' {' '.join(dq_parts)}" - - -def _lower_single_instruction_heredocs( - keyword: str, - header_text: str, - openers: list[tuple[int, int, bool, bool, str]], - bodies: list[list[str]], -) -> list[str]: - """Lower a single `RUN` or `COPY`/`ADD` instruction with BuildKit heredocs to classic `RUN`.""" - if keyword in ("COPY", "ADD"): - # Strip keyword and heredoc tokens to parse flags (--chmod, --chown) and destination path. - spans = [(s, e) for s, e, _, _, _ in openers] - cleaned_chars: list[str] = [] - cursor = 0 - for s, e in spans: - cleaned_chars.append(header_text[cursor:s]) - cursor = e - cleaned_chars.append(header_text[cursor:]) - remainder = "".join(cleaned_chars).split(None, 1) - tail = remainder[1] if len(remainder) > 1 else "" - try: - tokens = shlex.split(tail) - except ValueError: - tokens = tail.split() - chmod_val: str | None = None - chown_val: str | None = None - pos_args: list[str] = [] - for tok in tokens: - if tok.startswith("--chmod="): - chmod_val = tok.split("=", 1)[1] - elif tok.startswith("--chown="): - chown_val = tok.split("=", 1)[1] - elif tok.startswith("--"): - continue - else: - pos_args.append(tok) - dest = pos_args[-1] if pos_args else "/tmp/heredoc" - multi = len(openers) > 1 or dest.endswith("/") - cmds: list[str] = [] - for (_, _, _, is_quoted, delim), body_lines in zip(openers, bodies, strict=True): - target = posixpath.join(dest.rstrip("/") or "/", delim) if multi else dest - parent = posixpath.dirname(target) or "/" - target_q = shlex.quote(target) - parent_q = shlex.quote(parent) - printf_cmd = _format_printf_heredoc_cmd( - body_lines, is_quoted=is_quoted, allow_cmd_subst=False - ) - step_parts = [f"mkdir -p {parent_q}", f"{printf_cmd} > {target_q}"] - if chmod_val: - step_parts.append(f"chmod {shlex.quote(chmod_val)} {target_q}") - if chown_val: - step_parts.append(f"chown {shlex.quote(chown_val)} {target_q}") - cmds.append(" && ".join(step_parts)) - return [f"RUN {' && '.join(cmds)}"] - - # keyword == "RUN" - # Remove leading "RUN" and any BuildKit "--mount=... / --network=... / --security=..." flags - after_kw = header_text.split(None, 1)[1] if len(header_text.split(None, 1)) > 1 else "" - first_start, first_end, _, first_quoted, _ = openers[0] - kw_offset = len(header_text) - len(after_kw) - rel_start = first_start - kw_offset - rel_end = first_end - kw_offset - before_token = after_kw[:rel_start].strip() - after_token = after_kw[rel_end:].strip() - - # Strip leading `--flag=...` tokens from before_token - flag_parts: list[str] = [] - while before_token.startswith("--"): - parts = before_token.split(None, 1) - flag_parts.append(parts[0]) - before_token = parts[1].strip() if len(parts) > 1 else "" - - body_lines = bodies[0] - if not before_token and not after_token and len(openers) == 1: - # Bare `RUN < "$_f" && chmod +x "$_f" ' - f'&& {{ {shebang} "$_f"; _rc=$?; rm -f "$_f"; exit $_rc; }}' - ] - printf_cmd = _format_printf_heredoc_cmd(body_lines, is_quoted=True) - return [ - f'RUN _f=$(mktemp) && {printf_cmd} > "$_f" ' - f'&& {{ sh "$_f"; _rc=$?; rm -f "$_f"; exit $_rc; }}' - ] - - if len(openers) == 1: - cmd_rest = f"{before_token} {after_token}".strip() - printf_cmd = _format_printf_heredoc_cmd(body_lines, is_quoted=first_quoted) - return [f"RUN {printf_cmd} | {{ {cmd_rest}; }}"] - - # Multiple heredocs in a single RUN instruction: materialize temp files and substitute paths - rebuilt: list[str] = [] - setup_parts: list[str] = [] - cleanup_paths: list[str] = [] - cursor = kw_offset - for idx, ((s, e, _, is_quoted, _), b_lines) in enumerate(zip(openers, bodies, strict=True)): - tmp_path = f"/tmp/.capsem_hd_{idx}" - cleanup_paths.append(tmp_path) - printf_cmd = _format_printf_heredoc_cmd(b_lines, is_quoted=is_quoted) - setup_parts.append(f"{printf_cmd} > {tmp_path}") - rebuilt.append(header_text[cursor:s]) - rebuilt.append(f"< {tmp_path}") - cursor = e - rebuilt.append(header_text[cursor:]) - cmd_str = "".join(rebuilt).strip() - while cmd_str.startswith("--"): - parts = cmd_str.split(None, 1) - cmd_str = parts[1].strip() if len(parts) > 1 else "" - rm_cmd = f"rm -f {' '.join(cleanup_paths)}" - return [f"RUN {' && '.join(setup_parts)} && {{ {cmd_str}; rc=$?; {rm_cmd}; exit $rc; }}"] - - -def lower_dockerfile_heredocs(text: str) -> str: - """Lower BuildKit `RUN <` heredocs for classic `docker build`. - - Capsem's guest `dockerd` runs with `"features": {"buildkit": false}` because BuildKit requires - `mount(2)` inside user/mount namespaces. Classic `docker build` splits Dockerfiles by physical - newline first and fails on multi-line BuildKit heredocs (`Unknown instruction` or `COPY requires - at least two arguments`). This function lowers `RUN` and `COPY`/`ADD` heredoc instructions into - equivalent single-line `RUN printf ...` instructions while leaving non-heredoc lines untouched. - """ - out: list[str] = [] - changed = False - for keyword, cur, raw, openers, bodies in _scan_dockerfile_instructions(text.splitlines()): - if openers: - changed = True - out.extend(_lower_single_instruction_heredocs(keyword, cur, openers, bodies)) - else: - out.extend(raw) - if not changed: - return text - trailing_nl = "\n" if text.endswith("\n") else "" - return "\n".join(out) + trailing_nl diff --git a/integrations/inspect-ai/inspect_capsem/containers/image_build.py b/integrations/inspect-ai/inspect_capsem/containers/image_build.py new file mode 100644 index 000000000..7fbd5c4e6 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/image_build.py @@ -0,0 +1,213 @@ +"""Host-side Docker image builder and cache orchestrator for `inspect-capsem`.""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import shutil +import subprocess +import tempfile +import threading +import uuid +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .build_ca import prepare_build_inputs +from .build_context import ( + compute_build_cache_key, + default_linux_platform, + iter_context_files, +) +from .build_grant import is_host_build_enabled +from .oci_ingest import ( + default_cache_dir, + ingest_docker_save_tar, + verify_cached_manifest_blobs, +) +from .oci_registry import ensure_registry_server + +DEFAULT_BUILD_TIMEOUT_SECS = 600.0 +_CACHE_KEY_GUARD = threading.Lock() +_CACHE_KEY_LOCKS: dict[str, threading.Lock] = {} + + +def _lock_for_cache_key(key: str) -> threading.Lock: + with _CACHE_KEY_GUARD: + lock = _CACHE_KEY_LOCKS.get(key) + if lock is None: + lock = threading.Lock() + _CACHE_KEY_LOCKS[key] = lock + return lock + + +def _read_valid_manifest_hex(key_file: Path, blobs_dir: Path) -> str | None: + if not key_file.is_file(): + return None + with contextlib.suppress(Exception): + cached = json.loads(key_file.read_text(encoding="utf-8")) + cand = str(cached.get("manifest_hex", "")) + if verify_cached_manifest_blobs(blobs_dir, cand): + return cand + key_file.unlink(missing_ok=True) + return None + + +def configured_build_timeout() -> float: + """Return the timeout in seconds for host `docker build` commands.""" + raw = os.environ.get("CAPSEM_INSPECT_BUILD_TIMEOUT", "").strip() + return float(raw) if raw else DEFAULT_BUILD_TIMEOUT_SECS + + +def run_host_build( + spec: Mapping[str, Any], + *, + cache_dir: Path | None = None, + platform: str | None = None, +) -> str: + """Run `docker build` + `docker image save`, ingest into `blobs/sha256`, return manifest hex.""" + if not is_host_build_enabled(): + raise RuntimeError( + "Host-side image builds are disabled; " + "set CAPSEM_INSPECT_HOST_BUILD=1 in the evaluator environment." + ) + docker_bin = shutil.which("docker") + if docker_bin is None: + raise RuntimeError( + "Docker CLI ('docker') is required on PATH to build " + "Compose 'build:' / Dockerfile sandboxes" + ) + c_dir = (cache_dir or default_cache_dir()).resolve() + eff_platform = (platform or default_linux_platform()).strip() + ctx_dir = Path(str(spec["context"])) + df_path = Path(str(spec["dockerfile"])) + if not df_path.is_file(): + raise FileNotFoundError(f"Dockerfile not found: {df_path}") + iter_context_files(ctx_dir) + + timeout = configured_build_timeout() + temp_tag = f"capsem-inspect-build:{uuid.uuid4().hex[:16]}" + network_mode = str(spec.get("network") or "none").strip() + + with tempfile.TemporaryDirectory(prefix="capsem-oci-build-") as tmp: + tmp_root = Path(tmp) + tar_path = tmp_root / "image.tar" + explicit_cfg = spec.get("docker_config_dir") + if explicit_cfg: + cfg_dir = Path(str(explicit_cfg)) + else: + cfg_dir = tmp_root / "docker-config" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "config.json").write_text('{"auths":{}}', encoding="utf-8") + + build_ctx, build_df = prepare_build_inputs(spec, tmp_root) + cmd = [ + docker_bin, + "--config", + str(cfg_dir), + "build", + "--platform", + eff_platform, + "--network", + network_mode, + "-t", + temp_tag, + "-f", + str(build_df), + ] + if spec.get("target"): + cmd.extend(["--target", str(spec["target"])]) + for k, v in sorted((spec.get("args") or {}).items()): + cmd.extend(["--build-arg", f"{k}={v}"]) + cmd.append(str(build_ctx)) + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip()[-2000:] + raise RuntimeError( + f"Host 'docker build' failed (exit {proc.returncode}):\n{detail}" + ) + save_proc = subprocess.run( + [ + docker_bin, + "--config", + str(cfg_dir), + "image", + "save", + "-o", + str(tar_path), + temp_tag, + ], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + if save_proc.returncode != 0: + detail = (save_proc.stderr or save_proc.stdout or "").strip()[-2000:] + raise RuntimeError( + f"Host 'docker image save' failed (exit {save_proc.returncode}):\n{detail}" + ) + return ingest_docker_save_tar(tar_path, cache_dir=c_dir) + except subprocess.TimeoutExpired as exc: + raise RuntimeError( + f"Host docker build timed out after {timeout}s " + "(adjust CAPSEM_INSPECT_BUILD_TIMEOUT)" + ) from exc + finally: + with contextlib.suppress(Exception): + subprocess.run( + [docker_bin, "--config", str(cfg_dir), "image", "rm", "-f", temp_tag], + capture_output=True, + timeout=30, + check=False, + ) + + +def build_and_stage_oci_image_sync( + spec: Mapping[str, Any], + *, + cache_dir: Path | None = None, + port: int | None = None, +) -> tuple[str, str]: + """Build or reuse a cached OCI image and return `(loopback_image_ref, ca_pem)`.""" + if not is_host_build_enabled(): + raise RuntimeError( + "Host-side image builds are disabled; " + "set CAPSEM_INSPECT_HOST_BUILD=1 in the evaluator environment." + ) + c_dir = (cache_dir or default_cache_dir()).resolve() + keys_dir = c_dir / "keys" + blobs_dir = c_dir / "blobs" / "sha256" + keys_dir.mkdir(parents=True, exist_ok=True) + blobs_dir.mkdir(parents=True, exist_ok=True) + + eff_platform = default_linux_platform() + cache_key = compute_build_cache_key(spec, platform=eff_platform) + key_file = keys_dir / f"{cache_key}.json" + manifest_hex = _read_valid_manifest_hex(key_file, blobs_dir) + if manifest_hex is None: + with _lock_for_cache_key(f"{c_dir}:{cache_key}"): + manifest_hex = _read_valid_manifest_hex(key_file, blobs_dir) + if manifest_hex is None: + manifest_hex = run_host_build(spec, cache_dir=c_dir, platform=eff_platform) + tmp_key = keys_dir / f".{cache_key}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + tmp_key.write_text(json.dumps({"manifest_hex": manifest_hex}), encoding="utf-8") + tmp_key.replace(key_file) + + bound_port, ca_pem = ensure_registry_server(cache_dir=c_dir, port=port) + return f"127.0.0.1:{bound_port}/inspect-capsem/build@sha256:{manifest_hex}", ca_pem + + +async def build_and_stage_oci_image(spec: Mapping[str, Any]) -> tuple[str, str]: + """Async wrapper around `build_and_stage_oci_image_sync`.""" + return await asyncio.to_thread(build_and_stage_oci_image_sync, spec) diff --git a/integrations/inspect-ai/inspect_capsem/containers/oci_ingest.py b/integrations/inspect-ai/inspect_capsem/containers/oci_ingest.py new file mode 100644 index 000000000..6d98d4fcf --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/oci_ingest.py @@ -0,0 +1,189 @@ +"""OCI-layout and legacy `docker image save` archive ingestion into content-addressed blobs.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tarfile +import uuid +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +_OCI_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json" +_DOCKER_MANIFEST_MEDIA_TYPE = "application/vnd.docker.distribution.manifest.v2+json" +_OCI_INDEX_MEDIA_TYPE = "application/vnd.oci.image.index.v1+json" +_DOCKER_LIST_MEDIA_TYPE = "application/vnd.docker.distribution.manifest.list.v2+json" +_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") + + +def default_cache_dir() -> Path: + """Return the root directory for cached OCI builds and local TLS certificates.""" + raw = os.environ.get("CAPSEM_INSPECT_BUILD_CACHE_DIR", "").strip() + if raw: + return Path(raw).expanduser().resolve() + return (Path.home() / ".cache" / "capsem" / "inspect-oci-builds").resolve() + + +def _write_blob_bytes(blobs_dir: Path, data: bytes) -> str: + hex_digest = hashlib.sha256(data).hexdigest() + target = blobs_dir / hex_digest + if not target.exists(): + tmp = blobs_dir / f".{hex_digest}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + tmp.write_bytes(data) + tmp.replace(target) + return hex_digest + + +def _resolve_tar_member( + name: str, members: Mapping[str, bytes], blobs_dir: Path +) -> tuple[str, int]: + norm = name.lstrip("./") + if norm.startswith("blobs/sha256/"): + hex_digest = norm.removeprefix("blobs/sha256/") + blob_file = blobs_dir / hex_digest + if blob_file.is_file(): + return hex_digest, blob_file.stat().st_size + if norm in members: + data = members[norm] + return _write_blob_bytes(blobs_dir, data), len(data) + raise RuntimeError(f"Missing referenced archive member {name!r} in docker save tar") + + +def _ingest_oci_layout_index(index_doc: Mapping[str, Any], blobs_dir: Path) -> str: + manifests = index_doc.get("manifests") + if not isinstance(manifests, list) or not manifests: + raise RuntimeError("OCI index.json contains no manifests") + chosen = next( + ( + m + for m in manifests + if isinstance(m, Mapping) + and (m.get("annotations") or {}).get("vnd.docker.reference.type") + != "attestation-manifest" + ), + manifests[0], + ) + digest = str(chosen.get("digest", "")).removeprefix("sha256:") + if not _SHA256_HEX_RE.match(digest) or not (blobs_dir / digest).is_file(): + raise RuntimeError(f"OCI index.json points to missing manifest blob sha256:{digest}") + media_type = str(chosen.get("mediaType", "")) + blob_bytes = (blobs_dir / digest).read_bytes() + doc = json.loads(blob_bytes.decode("utf-8")) + if media_type in (_OCI_INDEX_MEDIA_TYPE, _DOCKER_LIST_MEDIA_TYPE) or "manifests" in doc: + return _ingest_oci_layout_index(doc, blobs_dir) + if doc.get("mediaType") == _DOCKER_MANIFEST_MEDIA_TYPE or "mediaType" not in doc: + doc["mediaType"] = _OCI_MANIFEST_MEDIA_TYPE + if isinstance(doc.get("config"), dict): + doc["config"]["mediaType"] = "application/vnd.oci.image.config.v1+json" + for layer in doc.get("layers") or []: + if isinstance(layer, dict): + mt = str(layer.get("mediaType", "")) + layer["mediaType"] = ( + "application/vnd.oci.image.layer.v1.tar+gzip" + if "gzip" in mt + else "application/vnd.oci.image.layer.v1.tar" + ) + return _write_blob_bytes(blobs_dir, json.dumps(doc, separators=(",", ":")).encode("utf-8")) + return digest + + +def _ingest_legacy_docker_manifest( + manifest_list: Any, members: Mapping[str, bytes], blobs_dir: Path +) -> str: + if not isinstance(manifest_list, list) or not manifest_list: + raise RuntimeError("Invalid legacy docker save manifest.json") + entry = manifest_list[0] + cfg_hex, cfg_size = _resolve_tar_member(str(entry["Config"]), members, blobs_dir) + layers: list[dict[str, Any]] = [] + for layer_path in entry.get("Layers") or []: + l_str = str(layer_path) + l_hex, l_size = _resolve_tar_member(l_str, members, blobs_dir) + media_type = ( + "application/vnd.oci.image.layer.v1.tar+gzip" + if l_str.endswith((".tar.gz", ".tgz")) + else "application/vnd.oci.image.layer.v1.tar" + ) + layers.append({"mediaType": media_type, "digest": f"sha256:{l_hex}", "size": l_size}) + oci_manifest = { + "schemaVersion": 2, + "mediaType": _OCI_MANIFEST_MEDIA_TYPE, + "config": { + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": f"sha256:{cfg_hex}", + "size": cfg_size, + }, + "layers": layers, + } + return _write_blob_bytes( + blobs_dir, json.dumps(oci_manifest, separators=(",", ":")).encode("utf-8") + ) + + +def ingest_docker_save_tar(tar_path: Path, cache_dir: Path | None = None) -> str: + """Ingest a `docker image save` tar archive into `blobs/sha256` and return manifest hex.""" + c_dir = (cache_dir or default_cache_dir()).resolve() + blobs_dir = c_dir / "blobs" / "sha256" + blobs_dir.mkdir(parents=True, exist_ok=True) + members: dict[str, bytes] = {} + with tarfile.open(tar_path, "r:*") as tf: + for member in tf.getmembers(): + if not member.isfile(): + continue + fobj = tf.extractfile(member) + if fobj is None: + continue + name = member.name.lstrip("./") + data = fobj.read() + if name.startswith("blobs/sha256/"): + hex_part = name.removeprefix("blobs/sha256/") + if _SHA256_HEX_RE.match(hex_part): + target = blobs_dir / hex_part + if not target.exists(): + tmp = blobs_dir / f".{hex_part}.{os.getpid()}.{uuid.uuid4().hex}.tmp" + tmp.write_bytes(data) + tmp.replace(target) + continue + members[name] = data + + if "index.json" in members: + return _ingest_oci_layout_index( + json.loads(members["index.json"].decode("utf-8")), blobs_dir + ) + if "manifest.json" in members: + return _ingest_legacy_docker_manifest( + json.loads(members["manifest.json"].decode("utf-8")), members, blobs_dir + ) + raise RuntimeError( + f"Unsupported docker save archive {tar_path}: missing index.json and manifest.json" + ) + + +def _blob_valid(blobs_dir: Path, digest_or_hex: str) -> bool: + hex_digest = digest_or_hex.removeprefix("sha256:") + if len(hex_digest) != 64: + return False + blob_file = blobs_dir / hex_digest + if not blob_file.is_file(): + return False + if hashlib.sha256(blob_file.read_bytes()).hexdigest() != hex_digest: + blob_file.unlink(missing_ok=True) + return False + return True + + +def verify_cached_manifest_blobs(blobs_dir: Path, manifest_hex: str) -> bool: + """Verify manifest, config, and layer blobs exist and match their SHA-256 digests.""" + if not _blob_valid(blobs_dir, manifest_hex): + return False + try: + doc = json.loads((blobs_dir / manifest_hex).read_bytes().decode("utf-8")) + cfg_digest = str(doc["config"]["digest"]) + layer_digests = [str(layer["digest"]) for layer in (doc.get("layers") or [])] + except Exception: + return False + cfg_ok = _blob_valid(blobs_dir, cfg_digest) + layers_ok = [_blob_valid(blobs_dir, ld) for ld in layer_digests] + return cfg_ok and all(layers_ok) diff --git a/integrations/inspect-ai/inspect_capsem/containers/oci_registry.py b/integrations/inspect-ai/inspect_capsem/containers/oci_registry.py new file mode 100644 index 000000000..28b8ea644 --- /dev/null +++ b/integrations/inspect-ai/inspect_capsem/containers/oci_registry.py @@ -0,0 +1,239 @@ +"""Local HTTPS OCI v2 loopback registry serving content-addressed blobs by digest.""" + +from __future__ import annotations + +import errno +import fcntl +import http.server +import re +import shutil +import socketserver +import ssl +import subprocess +import tempfile +import threading +import urllib.request +from pathlib import Path +from typing import Any + +from .oci_ingest import _OCI_MANIFEST_MEDIA_TYPE, default_cache_dir + +DEFAULT_REGISTRY_PORT = 5055 +_MANIFEST_PATH_RE = re.compile(r"^/v2/.+/manifests/sha256:([0-9a-f]{64})$") +_BLOB_PATH_RE = re.compile(r"^/v2/.+/blobs/sha256:([0-9a-f]{64})$") +_LOCK = threading.Lock() +_TLS_LOCK = threading.Lock() +_SERVERS: dict[tuple[str, int], tuple[http.server.HTTPServer, int, str]] = {} + + +def configured_registry_port() -> int: + """Return the configured loopback OCI registry port (`5055` by default).""" + import os + + raw = os.environ.get("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", "").strip() + return int(raw) if raw else DEFAULT_REGISTRY_PORT + + +def ensure_localhost_tls(cache_dir: Path | None = None) -> tuple[Path, Path, str]: + """Ensure a local CA and `127.0.0.1` server certificate exist; return `(cert, key, ca_pem)`.""" + c_dir = (cache_dir or default_cache_dir()).resolve() + tls_dir = c_dir / "tls" + tls_dir.mkdir(parents=True, exist_ok=True) + ca_cert = tls_dir / "ca.pem" + srv_cert = tls_dir / "server.pem" + srv_key = tls_dir / "server.key" + lock_path = tls_dir / ".tls.lock" + with _TLS_LOCK, lock_path.open("a+", encoding="utf-8") as lock_fh: + fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX) + if ca_cert.is_file() and srv_cert.is_file() and srv_key.is_file(): + return srv_cert, srv_key, ca_cert.read_text(encoding="utf-8") + + openssl = shutil.which("openssl") + if openssl is None: + raise RuntimeError( + "openssl is required on PATH to mint loopback OCI registry TLS certs" + ) + + with tempfile.TemporaryDirectory(dir=tls_dir, prefix=".tls-") as tmp: + t_dir = Path(tmp) + cnf, csr = t_dir / "openssl.cnf", t_dir / "server.csr" + t_ca_key, t_ca_cert = t_dir / "ca.key", t_dir / "ca.pem" + t_srv_key, t_srv_cert = t_dir / "server.key", t_dir / "server.pem" + cnf.write_text( + "[req]\ndistinguished_name=dn\nprompt=no\n" + "[dn]\nCN=Capsem Local Build CA\n" + "[ca]\nbasicConstraints=critical,CA:TRUE,pathlen:0\n" + "keyUsage=critical,keyCertSign,cRLSign\n" + "subjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always,issuer\n" + "[srv]\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", + encoding="utf-8", + ) + ec_args = ["-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes"] + for cmd in ( + [ + openssl, + "req", + "-x509", + *ec_args, + "-days", + "3650", + "-keyout", + str(t_ca_key), + "-out", + str(t_ca_cert), + "-config", + str(cnf), + "-extensions", + "ca", + ], + [ + openssl, + "req", + *ec_args, + "-keyout", + str(t_srv_key), + "-out", + str(csr), + "-subj", + "/CN=127.0.0.1", + ], + [ + openssl, + "x509", + "-req", + "-in", + str(csr), + "-CA", + str(t_ca_cert), + "-CAkey", + str(t_ca_key), + "-CAcreateserial", + "-out", + str(t_srv_cert), + "-days", + "3650", + "-extfile", + str(cnf), + "-extensions", + "srv", + ], + ): + subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=15) + for name in ("ca.key", "server.key", "server.pem", "ca.pem"): + (t_dir / name).replace(tls_dir / name) + return srv_cert, srv_key, ca_cert.read_text(encoding="utf-8") + + +class _ReusableHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def _make_handler(blobs_dir: Path) -> type[http.server.BaseHTTPRequestHandler]: + class _OciHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return + + def _send_headers_only(self, code: int, headers: dict[str, str]) -> None: + self.send_response(code) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + + def _handle(self, *, head_only: bool) -> None: + path = self.path.split("?", 1)[0] + if path in ("/v2", "/v2/"): + self._send_headers_only( + 200, + {"Docker-Distribution-Api-Version": "registry/2.0", "Content-Length": "2"}, + ) + if not head_only: + self.wfile.write(b"{}") + return + + m_match = _MANIFEST_PATH_RE.match(path) + b_match = _BLOB_PATH_RE.match(path) if m_match is None else None + match = m_match or b_match + if match is None: + self._send_headers_only(404, {"Content-Length": "0"}) + return + hex_digest = match.group(1) + blob_file = blobs_dir / hex_digest + if not blob_file.is_file(): + self._send_headers_only(404, {"Content-Length": "0"}) + return + data = blob_file.read_bytes() + ctype = _OCI_MANIFEST_MEDIA_TYPE if m_match else "application/octet-stream" + self._send_headers_only( + 200, + { + "Content-Type": ctype, + "Content-Length": str(len(data)), + "Docker-Content-Digest": f"sha256:{hex_digest}", + }, + ) + if not head_only: + self.wfile.write(data) + + def do_HEAD(self) -> None: + self._handle(head_only=True) + + def do_GET(self) -> None: + self._handle(head_only=False) + + return _OciHandler + + +def _probe_existing_registry(port: int, ca_pem: str) -> bool: + if port <= 0: + return False + ctx = ssl.create_default_context(cadata=ca_pem) + req = urllib.request.Request(f"https://127.0.0.1:{port}/v2/", method="GET") + try: + with urllib.request.urlopen(req, context=ctx, timeout=2.0) as resp: + return int(resp.status) == 200 + except Exception: + return False + + +def ensure_registry_server( + cache_dir: Path | None = None, port: int | None = None +) -> tuple[int, str]: + """Ensure the background loopback HTTPS OCI registry server is running.""" + c_dir = (cache_dir or default_cache_dir()).resolve() + req_port = configured_registry_port() if port is None else int(port) + key = (str(c_dir), req_port) + with _LOCK: + if key in _SERVERS: + _, bound_port, ca_pem = _SERVERS[key] + return bound_port, ca_pem + + srv_cert, srv_key, ca_pem = ensure_localhost_tls(c_dir) + blobs_dir = c_dir / "blobs" / "sha256" + blobs_dir.mkdir(parents=True, exist_ok=True) + try: + httpd = _ReusableHTTPServer(("127.0.0.1", req_port), _make_handler(blobs_dir)) + except OSError as exc: + if exc.errno == errno.EADDRINUSE and _probe_existing_registry(req_port, ca_pem): + return req_port, ca_pem + raise RuntimeError( + f"Failed to bind loopback OCI registry on 127.0.0.1:{req_port} ({exc}); " + "set CAPSEM_INSPECT_BUILD_REGISTRY_PORT to an available port" + ) from exc + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(certfile=str(srv_cert), keyfile=str(srv_key)) + httpd.socket = ssl_ctx.wrap_socket(httpd.socket, server_side=True) + bound_port = int(httpd.server_address[1]) + thread = threading.Thread( + target=httpd.serve_forever, name=f"capsem-oci-reg-{bound_port}", daemon=True + ) + thread.start() + _SERVERS[key] = (httpd, bound_port, ca_pem) + return bound_port, ca_pem diff --git a/integrations/inspect-ai/inspect_capsem/containers/runtime.py b/integrations/inspect-ai/inspect_capsem/containers/runtime.py index 8475bfd5f..0ec9b62a5 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/runtime.py +++ b/integrations/inspect-ai/inspect_capsem/containers/runtime.py @@ -206,6 +206,10 @@ async def prepare_oci_workload_container( await controller.exec_in_vm( vm_id, f"mkdir -p {q_wd} && (chmod a+rx {q_wd} 2>/dev/null || true)" ) + if spec.build and spec.build.get("ca_pem_file"): + from .dockerfile_ca import _CA_NSS_SCRIPT + + await controller.exec_in_vm(vm_id, _CA_NSS_SCRIPT, timeout=15) if spec.healthcheck: await _wait_for_oci_healthcheck(controller, vm_id, spec.healthcheck) diff --git a/integrations/inspect-ai/inspect_capsem/containers/spec.py b/integrations/inspect-ai/inspect_capsem/containers/spec.py index e87c169a8..75f5bb6fe 100644 --- a/integrations/inspect-ai/inspect_capsem/containers/spec.py +++ b/integrations/inspect-ai/inspect_capsem/containers/spec.py @@ -20,3 +20,4 @@ class ContainerSpec: mem_limit: str | None = None user: str | None = None allowed_host_paths: tuple[str, ...] = () + build: dict[str, Any] | None = None diff --git a/integrations/inspect-ai/tests/conftest.py b/integrations/inspect-ai/tests/conftest.py index 06c853166..2be97cc13 100644 --- a/integrations/inspect-ai/tests/conftest.py +++ b/integrations/inspect-ai/tests/conftest.py @@ -51,6 +51,9 @@ def pytest_configure(config: pytest.Config) -> None: if tmp_factory is not None: tmp_factory._given_basetemp = Path(namespaced).resolve() tmp_factory._basetemp = None + config.addinivalue_line( + "markers", "requires_docker: test requires a local docker CLI and daemon" + ) _PORTABLE_TIMEOUT_SHIM = f"""#!{sys.executable} diff --git a/integrations/inspect-ai/tests/containers/test_build_ca.py b/integrations/inspect-ai/tests/containers/test_build_ca.py new file mode 100644 index 000000000..5ce73603f --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_build_ca.py @@ -0,0 +1,221 @@ +"""Tests for PR 340b: CA bundle staging, rotation, NSS seeding, and BuildKit requirement.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import textwrap +from pathlib import Path +from typing import Any, cast + +import pytest +from inspect_capsem import sandbox as sb_mod +from inspect_capsem.config import CapsemSandboxConfig +from inspect_capsem.containers import HostBuildGrant, prepare_oci_workload_container +from inspect_capsem.containers import build_ca as bca_mod +from inspect_capsem.containers import build_context as bc_mod +from inspect_capsem.containers import dockerfile_ca as ca_mod +from inspect_capsem.containers import image_build as ib_mod +from inspect_capsem.containers import oci_ingest as ing_mod + +from ..helpers import Scripted +from .test_oci_registry import _make_oci_tar + +_FAKE_DOCKER_SCRIPT = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import json, os, shutil, sys + from pathlib import Path + + args = sys.argv[3:] if sys.argv[1:2] == ["--config"] else sys.argv[1:] + if args[:1] == ["build"]: + df = Path(args[args.index("-f") + 1]).read_text(encoding="utf-8") + ctx = Path(args[-1]) + log_p = Path(os.environ["FAKE_DOCKER_LOG"]) + prev = json.loads(log_p.read_text(encoding="utf-8")) if log_p.is_file() else [] + log_p.write_text(json.dumps([*prev, df]), encoding="utf-8") + rootfs = os.environ.get("FAKE_DOCKER_ROOTFS") + if rootfs and (ctx / ".capsem-ca.crt").is_file(): + dst = Path(rootfs) / "usr/local/share/ca-certificates/capsem-ca.crt" + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(ctx / ".capsem-ca.crt", dst) + if args[:2] == ["image", "save"]: + shutil.copyfile(os.environ["FAKE_DOCKER_TAR"], args[args.index("-o") + 1]) + """ +) + + +def _install_fake_docker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + bin_dir, tar_path = tmp_path / "bin", tmp_path / "template_oci.tar" + bin_dir.mkdir(exist_ok=True) + _make_oci_tar(tar_path) + docker_bin = bin_dir / "docker" + docker_bin.write_text(_FAKE_DOCKER_SCRIPT, encoding="utf-8") + docker_bin.chmod(0o755) + log_path = tmp_path / "docker_builds.json" + monkeypatch.setenv("PATH", f"{bin_dir}:{os.environ.get('PATH', '')}") + monkeypatch.setenv("FAKE_DOCKER_LOG", str(log_path)) + monkeypatch.setenv("FAKE_DOCKER_TAR", str(tar_path)) + return log_path + + +def _set_build_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, **extra: str) -> None: + base = { + "HOST_BUILD": "1", + "ALLOWED_HOST_PATHS": str(tmp_path), + "BUILD_CACHE_DIR": str(tmp_path / "cache"), + "BUILD_REGISTRY_PORT": "0", + **extra, + } + for k, v in base.items(): + monkeypatch.setenv(f"CAPSEM_INSPECT_{k}", v) + + +def test_ca_grant_narrowing_and_context_staging( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ctx = tmp_path / "ctx" + ctx.mkdir() + (ctx / "Dockerfile").write_text("FROM alpine:3.20\nRUN echo ok\n", encoding="utf-8") + (ctx / ".dockerignore").write_text("*\n", encoding="utf-8") + ca_file, bundle_file = tmp_path / "capsem-ca.crt", tmp_path / "ca-certificates.crt" + ca_file.write_text("-----BEGIN CERTIFICATE-----\nCAPSEM-CA-1\n-----END CERTIFICATE-----\n") + bundle_file.write_text("-----BEGIN CERTIFICATE-----\nPUBLIC-ROOT\n-----END CERTIFICATE-----\n") + _set_build_env(monkeypatch, tmp_path, BUILD_NETWORK="default") + + b_arg = {"context": str(ctx)} + with pytest.raises(TypeError, match="ca_pem must be a bool"): + HostBuildGrant(ca_pem=cast(Any, str(ca_file))) + with pytest.raises(TypeError, match="ca_pem must be a bool"): + HostBuildGrant.from_value({"ca_pem": str(ca_file)}) + with pytest.raises(ValueError, match="Unknown HostBuildGrant fields"): + HostBuildGrant.from_value({"ca_bundle": str(bundle_file)}) + + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CA_PEM_FILE", str(ca_file)) + with pytest.raises(ValueError, match="CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE"): + CapsemSandboxConfig(build=b_arg) + + opt_out = CapsemSandboxConfig(build=b_arg, host_build=HostBuildGrant(ca_pem=False)) + assert opt_out.build is not None and opt_out.build["ca_pem_file"] is None + + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE", str(bundle_file)) + cfg = CapsemSandboxConfig(build=b_arg, host_build=HostBuildGrant(ca_pem=True)) + assert cfg.build is not None + assert cfg.build["ca_pem_file"] == str(ca_file.resolve()) + assert cfg.build["ca_bundle_file"] == str(bundle_file.resolve()) + + staged = tmp_path / "staged" + bca_mod.stage_capsem_ca_in_context(ctx, staged, cfg.build) + assert (staged / ".capsem-ca.crt").read_text() == ca_file.read_text() + merged = (staged / ".capsem-ca-bundle.crt").read_text() + assert "PUBLIC-ROOT" in merged and "CAPSEM-CA-1" in merged + dignore = (staged / ".dockerignore").read_text() + assert "!.capsem-ca.crt" in dignore and "!.capsem-ca-bundle.crt" in dignore + + +async def test_ca_rotation_corrupt_blob_unlink_and_nss_seeding( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log_path = _install_fake_docker(tmp_path, monkeypatch) + cache_dir, ctx, rootfs = tmp_path / "cache", tmp_path / "app", tmp_path / "rootfs" + ctx.mkdir() + df, ca_file, bundle_file = ctx / "Dockerfile", tmp_path / "ca.crt", tmp_path / "bundle.crt" + df.write_text("FROM alpine:3.20\nRUN echo hi\n", encoding="utf-8") + key_out = ["-keyout", str(tmp_path / "ca.key"), "-out", str(ca_file)] + openssl_cmd = ["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", *key_out] + subprocess.run( + [*openssl_cmd, "-days", "1", "-subj", "/CN=Capsem Test CA"], + check=True, + capture_output=True, + ) + bundle_file.write_text("-----BEGIN CERTIFICATE-----\nBASE-ROOT\n-----END CERTIFICATE-----\n") + monkeypatch.setenv("FAKE_DOCKER_ROOTFS", str(rootfs)) + _set_build_env( + monkeypatch, tmp_path, BUILD_CA_PEM_FILE=str(ca_file), BUILD_CA_BUNDLE_FILE=str(bundle_file) + ) + + spec = CapsemSandboxConfig(build={"context": str(ctx)}).build + assert spec is not None + key1 = bc_mod.compute_build_cache_key(spec) + ref1, _ = ib_mod.build_and_stage_oci_image_sync(spec) + seen = json.loads(log_path.read_text(encoding="utf-8")) + assert len(seen) == 1 and ".capsem-ca-bundle.crt" in seen[0] and "certutil" in seen[0] + + in_image_ca = rootfs / "usr/local/share/ca-certificates/capsem-ca.crt" + assert in_image_ca.read_text(encoding="utf-8") == ca_file.read_text(encoding="utf-8") + nss_home = tmp_path / "nss-home" + nss_script = ca_mod._CA_NSS_SCRIPT.replace(ca_mod._IMAGE_CA, str(in_image_ca)) + subprocess.run(["sh", "-c", nss_script], env={**os.environ, "HOME": str(nss_home)}, check=True) + if shutil.which("certutil"): + cmd = ["certutil", "-d", f"sql:{nss_home}/.pki/nssdb", "-L", "-n", "capsem-ca"] + listed = subprocess.run(cmd, check=True, capture_output=True, text=True) + assert "Capsem Test CA" in listed.stdout + + m_hex = ref1.split("@sha256:", 1)[1] + blobs_dir = cache_dir / "blobs" / "sha256" + m_doc = json.loads((blobs_dir / m_hex).read_bytes().decode("utf-8")) + layer_path = blobs_dir / str(m_doc["layers"][0]["digest"]).removeprefix("sha256:") + layer_path.write_bytes(b"corrupted-bytes") + assert not ing_mod.verify_cached_manifest_blobs(blobs_dir, m_hex) and not layer_path.exists() + ib_mod.build_and_stage_oci_image_sync(spec) + assert len(json.loads(log_path.read_text(encoding="utf-8"))) == 2 and layer_path.is_file() + + ca_file.write_text(ca_file.read_text() + "\n# rotated\n", encoding="utf-8") + assert bc_mod.compute_build_cache_key(spec) != key1 + ib_mod.build_and_stage_oci_image_sync(spec) + assert len(json.loads(log_path.read_text(encoding="utf-8"))) == 3 + + ctrl = Scripted() + await prepare_oci_workload_container( + ctrl, "vm-ca", CapsemSandboxConfig(build={"context": str(ctx)}).to_container_spec() + ) + assert any("certutil" in c for c in ctrl.commands) + + +async def test_buildkit_required_and_sample_init_immutability( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log_path = _install_fake_docker(tmp_path, monkeypatch) + cache_dir, ctx = tmp_path / "cache", tmp_path / "app" + ctx.mkdir() + df = ctx / "Dockerfile" + df.write_text("FROM alpine:3.20\nRUN < None: + return None + + monkeypatch.setattr(sb_mod, "bake_sandbox_tools_into_controller", _noop_bake) + shared_cfg = CapsemSandboxConfig(dockerfile=str(df)) + h0 = hash(shared_cfg) + envs = await sb_mod.CapsemSandboxEnvironment.sample_init("t", shared_cfg, {}) + env = envs["default"] + assert isinstance(env, sb_mod.CapsemSandboxEnvironment) + assert (env._working_dir, env._user) == ("/", None) + assert (shared_cfg.working_dir, shared_cfg.user, hash(shared_cfg)) == (None, None, h0) + assert not shared_cfg.to_container_spec().working_dir_explicit + await sb_mod.CapsemSandboxEnvironment.sample_cleanup("t", None, envs, False) + + explicit_cfg = CapsemSandboxConfig( + dockerfile=str(df), working_dir="/explicit/dir", user="2000:2000" + ) + envs2 = await sb_mod.CapsemSandboxEnvironment.sample_init("t2", explicit_cfg, {}) + env2 = envs2["default"] + assert isinstance(env2, sb_mod.CapsemSandboxEnvironment) + assert env2._working_dir == "/explicit/dir" and env2._user == "2000:2000" + await sb_mod.CapsemSandboxEnvironment.sample_cleanup("t2", None, envs2, False) diff --git a/integrations/inspect-ai/tests/containers/test_build_context.py b/integrations/inspect-ai/tests/containers/test_build_context.py new file mode 100644 index 000000000..8fdd437be --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_build_context.py @@ -0,0 +1,119 @@ +"""Unit tests for `build_context` (`.dockerignore`, symlink containment, and cache keys).""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from inspect_capsem.containers import build_context as bc_mod +from inspect_capsem.containers import build_grant as bg_mod + + +def test_dockerignore_symlink_containment_and_cache_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + ctx_dir, outside = tmp_path / "ctx", tmp_path / "outside" + ctx_dir.mkdir() + outside.mkdir() + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(ctx_dir)) + secret_file = outside / "secret.txt" + secret_file.write_text("top-secret", encoding="utf-8") + (ctx_dir / "Containerfile").write_text("FROM scratch\n", encoding="utf-8") + (ctx_dir / "kept.txt").write_text("v1", encoding="utf-8") + (ctx_dir / "internal_link.txt").symlink_to("kept.txt") + (ctx_dir / "ignored.log").write_text("log-v1", encoding="utf-8") + sub = ctx_dir / "sub" + sub.mkdir() + (sub / "deep.txt").write_text("deep", encoding="utf-8") + (ctx_dir / ".dockerignore").write_text( + "# comment\n\n./*.log\n/sub/**\n!sub/deep.txt\nignored_escape_link\nignored_dir_link\n", + encoding="utf-8", + ) + (ctx_dir / "ignored_escape_link").symlink_to(secret_file) + (ctx_dir / "ignored_dir_link").symlink_to(outside) + + spec = bg_mod.resolve_direct_host_build( + {"build": {"context": str(ctx_dir), "args": ["A=1"], "target": "prod"}} + ) + assert spec is not None and spec["dockerfile"] == str((ctx_dir / "Containerfile").resolve()) + assert spec["args"] == {"A": "1"} and spec["target"] == "prod" + k1 = bc_mod.compute_build_cache_key(spec, platform="linux/amd64") + assert "ignored.log" not in dict(bc_mod.iter_context_files(ctx_dir)) + assert "ignored.log" in dict(bc_mod.iter_context_files(ctx_dir, include_ignored_regular=True)) + (ctx_dir / "ignored.log").write_text("log-v2-modified", encoding="utf-8") + k2 = bc_mod.compute_build_cache_key(spec, platform="linux/amd64") + assert k2 != k1 + assert bc_mod.compute_build_cache_key(spec, platform="linux/amd64", ca_fingerprint="fp1") != k2 + assert bc_mod.compute_build_cache_key( + spec, platform="linux/amd64", ca_fingerprint="fp1" + ) != bc_mod.compute_build_cache_key(spec, platform="linux/amd64", ca_fingerprint="fp2") + (sub / "deep.txt").write_text("deep-modified", encoding="utf-8") + assert bc_mod.compute_build_cache_key(spec, platform="linux/amd64") != k2 + + for link_name, target in (("escape_file.txt", secret_file), ("escape_dir", outside)): + link_p = ctx_dir / link_name + link_p.symlink_to(target) + with pytest.raises(ValueError, match=link_name): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + link_p.unlink() + + # Ignored directory without negation rules skips subtree walk unless include_ignored_regular=True, + # and non-regular files (e.g. FIFOs) inside ignored directories are skipped without blocking. + (ctx_dir / ".dockerignore").write_text( + "sub\nignored_dir_link\nignored_escape_link\n", encoding="utf-8" + ) + fifo_path = sub / "pipe" + os.mkfifo(fifo_path) + assert "sub/deep.txt" not in dict(bc_mod.iter_context_files(ctx_dir)) + incl_entries = dict(bc_mod.iter_context_files(ctx_dir, include_ignored_regular=True)) + assert "sub/deep.txt" in incl_entries + assert "sub/pipe" not in incl_entries + assert bc_mod.compute_build_cache_key(spec, platform="linux/amd64") + fifo_path.unlink() + + # .dockerignore symlink escaping context is rejected + (ctx_dir / ".dockerignore").unlink() + (ctx_dir / ".dockerignore").symlink_to(secret_file) + with pytest.raises(ValueError, match=r"\.dockerignore symlink escapes"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + (ctx_dir / ".dockerignore").unlink() + (ctx_dir / "ignored_escape_link").unlink() + (ctx_dir / "ignored_dir_link").unlink() + + # Dockerfile containment and file type checks + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", f"{ctx_dir},{outside}") + (ctx_dir / ".dockerignore").write_text("Dockerfile\n", encoding="utf-8") + df_link = ctx_dir / "Dockerfile" + df_link.symlink_to(secret_file) + with pytest.raises(ValueError, match="escapes build context"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + df_link.unlink() + + with pytest.raises(ValueError, match="not a regular file"): + bg_mod.resolve_direct_host_build({"build": {"context": str(ctx_dir), "dockerfile": "sub"}}) + with pytest.raises(ValueError, match="not a directory"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir / "kept.txt")}) + + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(ctx_dir)) + with pytest.raises(ValueError, match=r"Dockerfile .* is not allowlisted"): + bg_mod.resolve_direct_host_build( + {"build": {"context": str(ctx_dir), "dockerfile": str(secret_file)}} + ) + + # Platform detection and missing path errors + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_PLATFORM", "linux/arm64") + assert bc_mod.default_linux_platform() == "linux/arm64" + monkeypatch.delenv("CAPSEM_INSPECT_BUILD_PLATFORM", raising=False) + for m_arch, exp_plat in (("aarch64", "linux/arm64"), ("x86_64", "linux/amd64")): + monkeypatch.setattr(bc_mod.py_platform, "machine", lambda m=m_arch: m) + assert bc_mod.default_linux_platform() == exp_plat + with pytest.raises(FileNotFoundError, match="Dockerfile path is required"): + bc_mod.compute_build_cache_key({"context": str(ctx_dir)}) + with pytest.raises(FileNotFoundError, match="Dockerfile not found"): + bc_mod.compute_build_cache_key({"context": str(ctx_dir), "dockerfile": str(ctx_dir / "No")}) + with pytest.raises(FileNotFoundError, match="Build context directory not found"): + bc_mod.compute_build_cache_key( + {"context": str(tmp_path / "no_dir"), "dockerfile": str(ctx_dir / "Containerfile")} + ) diff --git a/integrations/inspect-ai/tests/containers/test_build_grant.py b/integrations/inspect-ai/tests/containers/test_build_grant.py new file mode 100644 index 000000000..b880f06fd --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_build_grant.py @@ -0,0 +1,147 @@ +"""Unit tests for `build_grant` (`HostBuildGrant`, env authority, and narrowing).""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import inspect_capsem +import pytest +from inspect_capsem.containers import HostBuildGrant +from inspect_capsem.containers import build_grant as bg_mod + + +def test_host_build_grant_env_authority_and_narrowing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("CAPSEM_INSPECT_HOST_BUILD", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_BUILD_NETWORK", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_BUILD_DOCKER_CONFIG", raising=False) + + assert inspect_capsem.HostBuildGrant is HostBuildGrant + with pytest.raises(AttributeError): + _ = inspect_capsem.NoSuchSymbol + + # Default off: task config cannot widen unset or disabled env + assert not bg_mod.is_host_build_enabled() + assert not bg_mod.is_host_build_enabled(True) + assert not bg_mod.is_host_build_enabled(HostBuildGrant(enabled=True)) + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "invalid") + with pytest.raises(ValueError, match="CAPSEM_INSPECT_HOST_BUILD"): + bg_mod.is_host_build_enabled() + + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + assert bg_mod.is_host_build_enabled() + assert bg_mod.is_host_build_enabled(True) + assert not bg_mod.is_host_build_enabled(False) + assert not bg_mod.is_host_build_enabled(HostBuildGrant(enabled=False)) + + # Validation of HostBuildGrant fields and network modes + grant_cls: Any = HostBuildGrant + for bad_net in ("host", "bridge"): + with pytest.raises(ValueError, match="network"): + grant_cls(network=bad_net) + for bad_key in ("unknown_key", "docker_config_dir"): + with pytest.raises(ValueError, match="Unknown HostBuildGrant fields"): + HostBuildGrant.from_value({bad_key: 1}) + with pytest.raises(TypeError, match="Expected HostBuildGrant"): + HostBuildGrant.from_value(123) + parsed_dict = HostBuildGrant.from_value({"allowed_contexts": f"{tmp_path},", "network": "none"}) + assert parsed_dict is not None and parsed_dict.to_dict()["network"] == "none" + + ctx_dir, other_dir, cfg_dir = tmp_path / "ctx", tmp_path / "other", tmp_path / "dcfg" + for d in (ctx_dir, other_dir, cfg_dir): + d.mkdir() + df_path = ctx_dir / "Dockerfile" + df_path.write_text("FROM scratch\n", encoding="utf-8") + + # Task allowed_contexts / allowed_host_paths cannot widen unset env + with pytest.raises(ValueError, match="CAPSEM_INSPECT_ALLOWED_HOST_PATHS"): + bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, + host_build=HostBuildGrant(allowed_contexts=(str(ctx_dir),)), + ) + with pytest.raises(ValueError, match="CAPSEM_INSPECT_ALLOWED_HOST_PATHS"): + bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, allowed_host_paths=(str(ctx_dir),) + ) + + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", f"{ctx_dir},{cfg_dir}") + # Task cannot widen to other_dir outside env roots + with pytest.raises(ValueError, match="not allowlisted"): + bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, allowed_host_paths=(str(other_dir),) + ) + with pytest.raises(ValueError, match="not allowlisted"): + bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, + host_build=HostBuildGrant(allowed_contexts=(str(other_dir),)), + ) + + # Network ceiling: default env is 'none', so task network='default' is refused + with pytest.raises(ValueError, match="exceeds operator ceiling"): + bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, + host_build=HostBuildGrant(network="default"), + ) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_NETWORK", "host") + with pytest.raises(ValueError, match="forbids network='host'"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_NETWORK", "bad") + with pytest.raises(ValueError, match="CAPSEM_INSPECT_BUILD_NETWORK"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_NETWORK", "default") + # Task can narrow default -> none, or match default + spec_narrow = bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir)}, host_build=HostBuildGrant(network="none") + ) + assert spec_narrow is not None and spec_narrow["network"] == "none" + + # Docker config dir env authority (CAPSEM_INSPECT_BUILD_DOCKER_CONFIG) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_DOCKER_CONFIG", str(other_dir)) + with pytest.raises(ValueError, match="not within CAPSEM_INSPECT_ALLOWED_HOST_PATHS"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_DOCKER_CONFIG", str(tmp_path / "missing_dcfg")) + with pytest.raises(ValueError, match="directory not found"): + bg_mod.resolve_direct_host_build({"build": str(ctx_dir)}) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_DOCKER_CONFIG", str(cfg_dir)) + spec_ok = bg_mod.resolve_direct_host_build( + {"build": str(ctx_dir), "dockerfile": "Dockerfile"}, + host_build=HostBuildGrant( + allowed_contexts=(str(ctx_dir),), + network="default", + ), + ) + assert spec_ok is not None + assert spec_ok["docker_config_dir"] == str(cfg_dir.resolve()) + assert spec_ok["network"] == "default" + + +def test_prepare_compose_build_service_validation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert bg_mod.resolve_direct_host_build({}) is None + for bad_svc, match in ( + ({"dockerfile": ""}, "non-empty path string"), + ({"build": "", "dockerfile": "Dockerfile"}, "context path string or mapping"), + ({"build": ""}, "non-empty context path string"), + ({"build": {"context": ""}}, "build.context"), + ({"build": {"dockerfile": 123}}, "build.dockerfile"), + ({"build": "git@github.com:org/repo.git"}, "Remote build context"), + ): + with pytest.raises(ValueError, match=match): + bg_mod.prepare_compose_build_service(bad_svc) + merged, stanza = bg_mod.prepare_compose_build_service( + {"build": {"target": "s1"}, "dockerfile": "Custom.df"} + ) + assert stanza == "build" and merged["build"] == { + "dockerfile": "Custom.df", + "target": "s1", + "context": ".", + } + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + with pytest.raises(ValueError, match="disabled by default"): + bg_mod.resolve_direct_host_build({"build": str(tmp_path)}, host_build=False) diff --git a/integrations/inspect-ai/tests/containers/test_compose.py b/integrations/inspect-ai/tests/containers/test_compose.py index fde64b7c5..79ecfb660 100644 --- a/integrations/inspect-ai/tests/containers/test_compose.py +++ b/integrations/inspect-ai/tests/containers/test_compose.py @@ -10,7 +10,9 @@ import inspect_capsem.containers.compose_fields as compose_fields_mod import pytest import yaml +from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment from inspect_capsem._compose import coerce_config +from inspect_capsem.containers import HostBuildGrant def test_compose_field_extraction_and_operator_allowlists( @@ -147,15 +149,42 @@ def test_compose_long_form_env_and_volumes( compose_fields_mod.normalize_volumes(bad_vols, tmp_path) -def test_unsupported_compose_features_rejected_at_coerce_config(tmp_path: Path) -> None: +def test_unsupported_compose_features_rejected_at_coerce_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> 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") + (tmp_path / "Dockerfile").write_text("FROM alpine:3.20\nWORKDIR /from-df\nUSER 1001\n") + compose_path.write_text(yaml.safe_dump({"services": {"app": {"build": "."}}})) + grant = HostBuildGrant(allowed_contexts=(str(tmp_path),)) + with pytest.raises(ValueError, match="CAPSEM_INSPECT_HOST_BUILD"): + coerce_config(str(compose_path)) + with pytest.raises(ValueError, match="CAPSEM_INSPECT_HOST_BUILD"): + coerce_config(CapsemSandboxConfig(compose_file=str(compose_path), host_build=grant)) + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + with pytest.raises(ValueError, match="CAPSEM_INSPECT_ALLOWED_HOST_PATHS"): + coerce_config(str(compose_path)) + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + + for valid_build in ( + {"build": "."}, + {"build": {"context": ".", "dockerfile": "Dockerfile"}}, + {"dockerfile": "Dockerfile"}, + ): + compose_path.write_text(yaml.safe_dump({"services": {"app": valid_build}})) + resolved = coerce_config( + CapsemSandboxConfig(compose_file=str(compose_path), host_build=grant) + ) + assert resolved.build is not None and resolved.execution_mode == "container" + assert CapsemSandboxEnvironment.config_deserialize(resolved.model_dump()) == resolved + assert CapsemSandboxConfig.model_validate_json(resolved.model_dump_json()) == resolved cases: tuple[tuple[dict[str, object], str], ...] = ( - ({"build": "."}, "build"), - ({"build": {"context": ".", "dockerfile": "Dockerfile"}}, "build"), - ({"build": {"dockerfile_inline": "FROM alpine\n"}}, "build"), - ({"dockerfile": "Dockerfile"}, "dockerfile"), + ({"build": {"dockerfile_inline": "FROM alpine\n"}}, "dockerfile_inline"), + ({"build": {"context": ".", "secrets": ["s"]}}, "Unsupported Compose build option"), + ({"build": 123}, "build"), + ({"build": "https://github.com/example/repo.git"}, "Remote build context"), + ({"build": "../outside"}, "CAPSEM_INSPECT_ALLOWED_HOST_PATHS"), ({"privileged": True}, "privileged"), *(({k: ["x"]}, k) for k in lkeys), *(({k: "host"}, k) for k in ("pid", "ipc", "uts", "cgroup")), diff --git a/integrations/inspect-ai/tests/containers/test_dockerfile_ca.py b/integrations/inspect-ai/tests/containers/test_dockerfile_ca.py index 12b3784c1..c658ee5b0 100644 --- a/integrations/inspect-ai/tests/containers/test_dockerfile_ca.py +++ b/integrations/inspect-ai/tests/containers/test_dockerfile_ca.py @@ -21,10 +21,11 @@ def test_ca_patch_multistage_scratch_and_continuations() -> None: ).splitlines() assert patched[:2] == ["# syntax comment", "FROM golang:1.22 AS Build"] # A stage with a shell-form RUN also merges the CA into its system store. - assert patched[2:6] == [*CA_LINES, CA_UPDATE] + n_ca = len(CA_LINES) + assert patched[2 : 2 + n_ca + 1] == [*CA_LINES, CA_UPDATE] idx = patched.index(" python:3.12 AS tools") # Exec-form RUN only: the base may have no shell, so no update RUN. - assert patched[idx + 1 : idx + 5] == [*CA_LINES, 'RUN ["pip", "install", "x"]'] + assert patched[idx + 1 : idx + 1 + n_ca + 1] == [*CA_LINES, 'RUN ["pip", "install", "x"]'] # Stages built from an earlier stage inherit the CA; scratch gets nothing. assert patched[-3:] == ["FROM build AS test", "FROM scratch", "COPY --from=build /out /out"] assert sum(line == CA_LINES[0] for line in patched) == 2 @@ -35,6 +36,8 @@ def test_ca_patch_keeps_image_bundle() -> None: patched = dockerfile_mod.patch_dockerfile_for_capsem_ca("FROM alpine:3.20\nRUN apk add curl\n") assert patched.splitlines() == ["FROM alpine:3.20", *CA_LINES, CA_UPDATE, "RUN apk add curl"] assert "/etc/ssl/certs/ca-certificates.crt" not in patched + assert "COPY .capsem-ca-bundle.crt /usr/local/share/capsem/ca-bundle.crt" in patched + assert "SSL_CERT_FILE=/usr/local/share/capsem/ca-bundle.crt" in patched assert "NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/capsem-ca.crt" in patched assert dockerfile_mod.patch_dockerfile_for_capsem_ca("FROM scratch\n") == "FROM scratch\n" distroless = "FROM gcr.io/distroless/python3\nCOPY app /app\n" @@ -69,11 +72,19 @@ def test_ca_patch_ignores_heredoc_and_continuation_bodies() -> None: def test_ca_patcher_edge_cases_escape_quoted_heredoc_and_derived_stage() -> None: - df_edge = "# escape=`\nFROM debian:12 AS base\nENV A=1 `\n # comment inside continuation\n B=2\nFROM base AS app\nUSER 1000\nRUN python3 -c \"x = a << EOF; y = '< None assert "FROM fake_inside_heredoc" in patched idx_fake = patched.index("FROM fake_inside_heredoc") assert patched[idx_fake + 1] == "EOF " - df_inherited_nonroot = "FROM ubuntu:24.04 AS parent\nENV FOO=1\nUSER appuser\nFROM parent AS child\nRUN curl -fsSL https://example.com\n" + df_inherited_nonroot = ( + "FROM ubuntu:24.04 AS parent\nENV FOO=1\nUSER appuser\n" + "FROM parent AS child\nRUN curl -fsSL https://example.com\n" + ) patched_inh = dockerfile_mod.patch_dockerfile_for_capsem_ca(df_inherited_nonroot).splitlines() child_idx = patched_inh.index("FROM parent AS child") assert patched_inh[child_idx + 1 : child_idx + 4] == ["USER root", CA_UPDATE, "USER appuser"] diff --git a/integrations/inspect-ai/tests/containers/test_dockerfile_heredoc.py b/integrations/inspect-ai/tests/containers/test_dockerfile_heredoc.py deleted file mode 100644 index 291244882..000000000 --- a/integrations/inspect-ai/tests/containers/test_dockerfile_heredoc.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Original pure lowering and real shell/interpreter semantics.""" - -import os -import subprocess -from pathlib import Path - -import inspect_capsem.containers.dockerfile as dockerfile_mod -import pytest - - -def test_lower_dockerfile_heredocs(tmp_path: Path) -> None: - """Lower BuildKit RUN < /etc/greeting.txt\nline1\nline2\nEOF\nRUN cat <<-EOF1 > /tmp/a && cat <<'EOF2' > /tmp/b\n\talpha\n\tEOF1\nbeta\nEOF2\nCOPY --chmod=0755 --chown=1000:1000 <<'SCRIPT' /usr/local/bin/entry.sh\n#!/bin/sh\nexec \"$@\"\nSCRIPT\nCOPY < /etc/greeting.txt; }" in ln for ln in lines) - assert any("/tmp/.capsem_hd_0" in ln and "/tmp/.capsem_hd_1" in ln for ln in lines) - assert any( - "mkdir -p /usr/local/bin" in ln - and "> /usr/local/bin/entry.sh" in ln - and ("chmod 0755 /usr/local/bin/entry.sh" in ln) - and ("chown 1000:1000 /usr/local/bin/entry.sh" in ln) - for ln in lines - ) - assert any("multi/F1" in ln and "multi/F2" in ln for ln in lines) - bare_df = 'FROM alpine:3.20\nRUN < None: + cache_dir = tmp_path / "oci_cache" + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", str(cache_dir)) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", "0") + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_TIMEOUT", "15") + assert ib_mod.configured_build_timeout() == 15.0 + ctx_dir = tmp_path / "app" + ctx_dir.mkdir() + df = ctx_dir / "Dockerfile" + df.write_text("FROM alpine:3.20\n", encoding="utf-8") + + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "0") + with pytest.raises(RuntimeError, match="CAPSEM_INSPECT_HOST_BUILD=1"): + await ib_mod.build_and_stage_oci_image({"context": str(ctx_dir), "dockerfile": str(df)}) + with pytest.raises(RuntimeError, match="CAPSEM_INSPECT_HOST_BUILD=1"): + ib_mod.run_host_build({"context": str(ctx_dir), "dockerfile": str(df)}) + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + + real_which, real_run = ib_mod.shutil.which, subprocess.run + monkeypatch.setattr(ib_mod.shutil, "which", lambda n: None if n == "docker" else real_which(n)) + with pytest.raises(RuntimeError, match="Docker CLI"): + await ib_mod.build_and_stage_oci_image({"context": str(ctx_dir), "dockerfile": str(df)}) + + calls: list[tuple[list[str], dict[str, str]]] = [] + fail_mode: str | None = None + + def fake_run(cmd: list[str], **kw: Any) -> subprocess.CompletedProcess[str]: + if cmd and "openssl" in cmd[0]: + return real_run(cmd, **kw) + calls.append((cmd, dict(kw.get("env") or {}))) + if fail_mode == "timeout": + raise subprocess.TimeoutExpired(cmd, kw.get("timeout", 15.0)) + if fail_mode and fail_mode in cmd: + return subprocess.CompletedProcess(cmd, 1, "", f"{fail_mode} failed") + if "build" in cmd: + time.sleep(0.02) + if "save" in cmd: + _make_oci_tar(Path(cmd[cmd.index("-o") + 1])) + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr( + ib_mod.shutil, "which", lambda n: "/usr/bin/docker" if n == "docker" else real_which(n) + ) + monkeypatch.setattr(ib_mod.subprocess, "run", fake_run) + + with pytest.raises(FileNotFoundError, match="Dockerfile not found"): + ib_mod.run_host_build({"context": str(ctx_dir), "dockerfile": str(ctx_dir / "Missing")}) + + dcfg_dir = tmp_path / "dcfg" + dcfg_dir.mkdir() + spec = { + "context": str(ctx_dir), + "dockerfile": str(df), + "target": "s1", + "args": {"K": "V"}, + "docker_config_dir": str(dcfg_dir), + } + for mode, match in ( + ("build", "Host 'docker build' failed"), + ("save", "Host 'docker image save' failed"), + ("timeout", "CAPSEM_INSPECT_BUILD_TIMEOUT"), + ): + fail_mode = mode + with pytest.raises(RuntimeError, match=match): + await ib_mod.build_and_stage_oci_image(spec) + fail_mode = None + calls.clear() + + # 4 concurrent cache-miss builds for the same spec execute `run_host_build` once + results = await asyncio.gather(*(ib_mod.build_and_stage_oci_image(spec) for _ in range(4))) + assert len(set(results)) == 1 and len(calls) == 3 + calls.clear() + + cfg = CapsemSandboxConfig(dockerfile=str(df)) + assert cfg.to_container_spec().build is not None + ctrl = Scripted() + assert await _init_sample_vm(ctrl, cfg, "task-build") == "vm-s" and len(calls) == 3 + build_cmd, _ = calls[0] + assert "--network" in build_cmd and build_cmd[build_cmd.index("--network") + 1] == "none" + assert "--config" in build_cmd and build_cmd[build_cmd.index("--config") + 1] != str( + Path.home() / ".docker" + ) + started = ctrl.started[-1] + assert ( + started["image"].startswith("127.0.0.1:") + and "BEGIN CERTIFICATE" in started["registry_ca_pem"] + ) + await _init_sample_vm(ctrl, cfg, "task-build-cached") + assert len(calls) == 3 + + # Corrupted layer triggers automatic rebuild + m_hex = started["image"].split("@sha256:", 1)[1] + blobs_dir = cache_dir / "blobs" / "sha256" + m_doc = json.loads((blobs_dir / m_hex).read_bytes().decode("utf-8")) + layer_hex = str(m_doc["layers"][0]["digest"]).removeprefix("sha256:") + (blobs_dir / layer_hex).write_bytes(b"corrupted-layer-bytes") + assert not ing_mod.verify_cached_manifest_blobs(blobs_dir, m_hex) + await _init_sample_vm(ctrl, cfg, "task-build-rebuild") + assert len(calls) == 6 + (blobs_dir / m_hex).write_bytes(b"not-json") + assert not ing_mod.verify_cached_manifest_blobs(blobs_dir, m_hex) + + class RejectingHypervisor: + async def create(self, **kwargs: Any) -> Any: + assert kwargs.get("registry") is not None + raise HttpError(403, "image source is not allowed") + + async def close(self) -> None: + return + + authority, r_ca = started["image"].split("/", 1)[0], started["registry_ca_pem"] + sdk_ctrl = SdkCapsemController(hypervisor=cast(Any, RejectingHypervisor())) + with pytest.raises(RuntimeError, match=f'admit = \\["{authority}/inspect-capsem/build"\\]'): + await sdk_ctrl.start_vm(cpu_count=2, ram_gb=4, image=started["image"], registry_ca_pem=r_ca) + + +@pytest.mark.requires_docker +def test_real_docker_frontend_offline_multistage_build( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + docker = shutil.which("docker") + if docker is None: + pytest.skip("docker CLI not installed") + return + if ( + subprocess.run([docker, "info"], capture_output=True, check=False, timeout=10).returncode + != 0 + ): + pytest.skip("docker daemon unavailable") + from ..oci_workload_fixture import ( + _build_rootfs_tar_bytes, + _extract_busybox_from_initrd, + _find_initrd_path, + ) + + try: + initrd = _find_initrd_path() + except RuntimeError: + matches = [ + m + for root in (Path.home() / "capsem/cache/target/assets", Path.home() / ".capsem/assets") + if root.is_dir() + for m in root.rglob("initrd.img") + if m.is_file() + ] + if not matches: + pytest.skip("initrd busybox unavailable") + initrd = matches[0] + busybox = _extract_busybox_from_initrd(initrd) + + base_tag = "inspect-capsem-unit-base:local" + subprocess.run( + [docker, "image", "import", "-", base_tag], + input=_build_rootfs_tar_bytes(busybox), + check=True, + capture_output=True, + timeout=30, + ) + ctx_dir = tmp_path / "proj" + ctx_dir.mkdir() + (ctx_dir / ".dockerignore").write_text("ignored.txt\n", encoding="utf-8") + (ctx_dir / "ignored.txt").write_text("must-not-be-copied\n", encoding="utf-8") + (ctx_dir / "kept.txt").write_text("kept-ok\n", encoding="utf-8") + tar_buf = io.BytesIO() + with tarfile.open(fileobj=tar_buf, mode="w") as tf: + ti, data = tarfile.TarInfo(name="added.txt"), b"from-add-tar\n" + ti.size, ti.mode = len(data), 0o644 + tf.addfile(ti, io.BytesIO(data)) + (ctx_dir / "archive.tar").write_bytes(tar_buf.getvalue()) + (ctx_dir / "Dockerfile").write_text( + f"ARG BASE={base_tag}\nFROM ${{BASE}} AS builder\nARG MSG=default\n" + 'RUN echo "shell-${MSG}" > /built.txt\n' + 'RUN ["/bin/sh", "-c", "echo exec-ok >> /built.txt"]\n' + "FROM scratch AS scratch_stage\nCOPY --from=builder /built.txt /built.txt\n" + "FROM ${BASE} AS final\nWORKDIR /workspace/app\nENV APP_MODE=hermetic\n" + "COPY --from=scratch_stage /built.txt /workspace/app/built.txt\n" + "COPY --chown=1000:1000 kept.txt /workspace/app/kept.txt\n" + "ADD archive.tar /workspace/app/unpacked/\n" + 'USER 1000:1000\nENTRYPOINT ["/bin/sh", "-c"]\nCMD ["sleep 3600"]\n', + encoding="utf-8", + ) + cache_dir = tmp_path / "oci_cache" + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(ctx_dir)) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", str(cache_dir)) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", "0") + grant = HostBuildGrant(allowed_contexts=(str(ctx_dir),), network="none") + cfg = CapsemSandboxConfig( + build={"context": str(ctx_dir), "target": "final", "args": {"MSG": "custom-arg"}}, + host_build=grant, + ) + assert cfg.build is not None + image_ref, ca_pem = ib_mod.build_and_stage_oci_image_sync(cfg.build) + assert image_ref.startswith("127.0.0.1:") and "BEGIN CERTIFICATE" in ca_pem + m_hex = image_ref.split("@sha256:", 1)[1] + blobs_dir = cache_dir / "blobs" / "sha256" + m_doc = json.loads((blobs_dir / m_hex).read_bytes().decode("utf-8")) + cfg_hex = str(m_doc["config"]["digest"]).removeprefix("sha256:") + cfg_doc = json.loads((blobs_dir / cfg_hex).read_bytes().decode("utf-8")) + assert cfg_doc["config"]["WorkingDir"] == "/workspace/app" + assert cfg_doc["config"]["User"] == "1000:1000" diff --git a/integrations/inspect-ai/tests/containers/test_oci_registry.py b/integrations/inspect-ai/tests/containers/test_oci_registry.py new file mode 100644 index 000000000..4b4f861f9 --- /dev/null +++ b/integrations/inspect-ai/tests/containers/test_oci_registry.py @@ -0,0 +1,189 @@ +"""Unit tests for `oci_ingest` and `oci_registry`.""" + +from __future__ import annotations + +import concurrent.futures +import hashlib +import io +import json +import ssl +import subprocess +import tarfile +import threading +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +import pytest +from inspect_capsem.containers import oci_ingest as ing_mod +from inspect_capsem.containers import oci_registry as reg_mod + +_MANIFEST_MT = "application/vnd.oci.image.manifest.v1+json" +_DOCKER_MT = "application/vnd.docker.distribution.manifest.v2+json" +_INDEX_MT = "application/vnd.oci.image.index.v1+json" + + +def _make_oci_tar( + tar_path: Path, + *, + nested_index: bool = False, + legacy_only: bool = False, + docker_v2: bool = False, +) -> str: + def _desc(mt: str, data: bytes) -> tuple[str, dict[str, Any]]: + h = hashlib.sha256(data).hexdigest() + return h, {"mediaType": mt, "digest": f"sha256:{h}", "size": len(data)} + + cfg_b, layer_b = b'{"architecture":"amd64","os":"linux"}', b"layer-tar-content" + cfg_hex, cfg_d = _desc("application/vnd.oci.image.config.v1+json", cfg_b) + layer_mt = ( + "application/vnd.docker.image.rootfs.diff.tar.gzip" + if docker_v2 + else "application/vnd.oci.image.layer.v1.tar" + ) + layer_hex, layer_d = _desc(layer_mt, layer_b) + m_mt = _DOCKER_MT if docker_v2 else _MANIFEST_MT + m_doc = {"schemaVersion": 2, "mediaType": m_mt, "config": cfg_d, "layers": [layer_d]} + m_bytes = json.dumps(m_doc, separators=(",", ":")).encode() + m_hex, m_d = _desc(m_mt, m_bytes) + + def _add(tf: tarfile.TarFile, name: str, data: bytes) -> None: + info = tarfile.TarInfo(name=name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + with tarfile.open(tar_path, "w") as tf: + if legacy_only: + _add(tf, f"{cfg_hex}.json", cfg_b) + _add(tf, "layer0/layer.tar.gz", layer_b) + leg = [{"Config": f"{cfg_hex}.json", "Layers": ["layer0/layer.tar.gz"]}] + _add(tf, "manifest.json", json.dumps(leg).encode()) + else: + for h, b in ((cfg_hex, cfg_b), (layer_hex, layer_b), (m_hex, m_bytes)): + _add(tf, f"blobs/sha256/{h}", b) + top_d = m_d + if nested_index: + att = {**m_d, "annotations": {"vnd.docker.reference.type": "attestation-manifest"}} + sub_b = json.dumps( + {"schemaVersion": 2, "mediaType": _INDEX_MT, "manifests": [att, m_d]} + ).encode() + sub_hex, top_d = _desc(_INDEX_MT, sub_b) + _add(tf, f"blobs/sha256/{sub_hex}", sub_b) + _add(tf, "index.json", json.dumps({"schemaVersion": 2, "manifests": [top_d]}).encode()) + return m_hex + + +def test_oci_ingest_and_registry_https_serving( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", raising=False) + assert ing_mod.default_cache_dir().name == "inspect-oci-builds" + assert reg_mod.configured_registry_port() == reg_mod.DEFAULT_REGISTRY_PORT + + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_CACHE_DIR", str(tmp_path / "cache with space")) + monkeypatch.setenv("CAPSEM_INSPECT_BUILD_REGISTRY_PORT", "0") + assert ing_mod.default_cache_dir() == (tmp_path / "cache with space").resolve() + assert reg_mod.configured_registry_port() == 0 + + real_which = reg_mod.shutil.which + monkeypatch.setattr(reg_mod.shutil, "which", lambda _: None) + with pytest.raises(RuntimeError, match="openssl is required"): + reg_mod.ensure_localhost_tls(tmp_path / "no_openssl") + monkeypatch.setattr(reg_mod.shutil, "which", real_which) + + flock_calls: list[int] = [] + real_flock = reg_mod.fcntl.flock + monkeypatch.setattr( + reg_mod.fcntl, + "flock", + lambda fd, op: (flock_calls.append(op), real_flock(fd, op))[1], + ) + cert_p, key_p, pem = reg_mod.ensure_localhost_tls(ing_mod.default_cache_dir()) + assert "BEGIN CERTIFICATE" in pem and cert_p.is_file() and key_p.is_file() + assert (cert_p.parent / ".tls.lock").is_file() + assert flock_calls == [reg_mod.fcntl.LOCK_EX] + assert reg_mod.ensure_localhost_tls(ing_mod.default_cache_dir())[2] == pem + assert flock_calls == [reg_mod.fcntl.LOCK_EX, reg_mod.fcntl.LOCK_EX] + monkeypatch.setattr(reg_mod.fcntl, "flock", real_flock) + for crt in (cert_p.parent / "ca.pem", cert_p): + txt = subprocess.check_output( + ["openssl", "x509", "-in", str(crt), "-noout", "-text"], text=True + ) + assert "X509v3 Subject Key Identifier" in txt and "X509v3 Authority Key Identifier" in txt + + tar1, tar_v2, tar_legacy = tmp_path / "oci1.tar", tmp_path / "v2.tar", tmp_path / "leg.tar" + m_hex = _make_oci_tar(tar1, nested_index=True) + _make_oci_tar(tar_v2, docker_v2=True) + _make_oci_tar(tar_legacy, legacy_only=True) + barrier = threading.Barrier(4) + + def _race_worker() -> tuple[str, str]: + barrier.wait(timeout=5.0) + ing_hex = ing_mod.ingest_docker_save_tar(tar1) + _, _, tls_pem = reg_mod.ensure_localhost_tls(tmp_path / "concurrent_tls") + return ing_hex, tls_pem + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + futs = [pool.submit(_race_worker) for _ in range(4)] + race_results = [f.result(timeout=15.0) for f in futs] + assert {r[0] for r in race_results} == {m_hex} + assert len({r[1] for r in race_results}) == 1 and "BEGIN CERTIFICATE" in race_results[0][1] + assert ing_mod.ingest_docker_save_tar(tar1) == m_hex + assert len(ing_mod.ingest_docker_save_tar(tar_v2)) == 64 + assert len(ing_mod.ingest_docker_save_tar(tar_legacy)) == 64 + + blobs_dir = ing_mod.default_cache_dir() / "blobs" / "sha256" + assert ing_mod._resolve_tar_member(f"blobs/sha256/{m_hex}", {}, blobs_dir)[0] == m_hex + assert ing_mod.verify_cached_manifest_blobs(blobs_dir, m_hex) + assert not ing_mod._blob_valid(blobs_dir, "short") + + empty_tar = tmp_path / "empty.tar" + with tarfile.open(empty_tar, "w"): + pass + with pytest.raises(RuntimeError, match="Unsupported docker save archive"): + ing_mod.ingest_docker_save_tar(empty_tar) + with pytest.raises(RuntimeError, match="Missing referenced archive member"): + ing_mod._resolve_tar_member("missing.tar", {}, tmp_path) + + port, ca_pem = reg_mod.ensure_registry_server(port=0) + assert port > 0 and ca_pem == pem and reg_mod.ensure_registry_server(port=0) == (port, ca_pem) + assert reg_mod._probe_existing_registry(port, ca_pem) is True + assert not reg_mod._probe_existing_registry(0, ca_pem) + assert not reg_mod._probe_existing_registry(1, ca_pem) + assert reg_mod.ensure_registry_server(port=port) == (port, ca_pem) + reg_mod._SERVERS.pop((str(ing_mod.default_cache_dir()), port), None) + assert reg_mod.ensure_registry_server(port=port) == (port, ca_pem) + + def _raise_eaddrinuse(*_a: Any, **_kw: Any) -> Any: + raise OSError(98, "Address already in use") + + monkeypatch.setattr(reg_mod, "_ReusableHTTPServer", _raise_eaddrinuse) + with pytest.raises(RuntimeError, match="CAPSEM_INSPECT_BUILD_REGISTRY_PORT"): + reg_mod.ensure_registry_server(port=1) + + ctx = ssl.create_default_context(cadata=ca_pem) + ctx.verify_flags |= getattr(ssl, "VERIFY_X509_STRICT", 0) + for method in ("GET", "HEAD"): + for path in ("/v2/", f"/v2/inspect-capsem/build/manifests/sha256:{m_hex}"): + req = urllib.request.Request(f"https://127.0.0.1:{port}{path}", method=method) + with urllib.request.urlopen(req, context=ctx) as resp: + assert resp.status == 200 + if method == "GET" and "manifests" in path: + cfg_digest = json.loads(resp.read().decode())["config"]["digest"] + req_b = urllib.request.Request( + f"https://127.0.0.1:{port}/v2/inspect-capsem/build/blobs/{cfg_digest}", method=method + ) + with urllib.request.urlopen(req_b, context=ctx) as resp: + assert resp.status == 200 + + for bad_path in ( + "/v2/unknown", + "/v2/inspect-capsem/build/manifests/latest", + f"/v2/inspect-capsem/build/blobs/sha256:{'0' * 64}", + ): + with pytest.raises(urllib.error.HTTPError) as exc_info: + urllib.request.urlopen(f"https://127.0.0.1:{port}{bad_path}", context=ctx) + exc_info.value.close() + assert exc_info.value.code == 404 diff --git a/integrations/inspect-ai/tests/host_build_acceptance.py b/integrations/inspect-ai/tests/host_build_acceptance.py new file mode 100644 index 000000000..ed8dad443 --- /dev/null +++ b/integrations/inspect-ai/tests/host_build_acceptance.py @@ -0,0 +1,224 @@ +"""Hermetic host-build (`compose.yaml` `build:` / `Dockerfile`) live VM acceptance check.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import inspect_capsem.containers.image_build as image_build_mod +from capsem import Hypervisor +from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment, HostBuildGrant +from inspect_capsem._tools import ( + INSPECT_SANDBOX_TOOLS_GUEST_PATH, + INSPECT_SANDBOX_TOOLS_ONEDIR_PATH, +) + +try: + import tests.host_build_fixture as _hb_fixture +except ImportError: + import importlib.util as _importlib_util + + _fixture_path = Path(__file__).resolve().with_name("host_build_fixture.py") + _fixture_spec = _importlib_util.spec_from_file_location( + "_capsem_host_build_fixture", _fixture_path + ) + assert _fixture_spec is not None and _fixture_spec.loader is not None + _hb_fixture = _importlib_util.module_from_spec(_fixture_spec) + _fixture_spec.loader.exec_module(_hb_fixture) + +_ENV_KEYS = ( + "CAPSEM_INSPECT_HOST_BUILD", + "CAPSEM_INSPECT_ALLOWED_HOST_PATHS", + "CAPSEM_INSPECT_BUILD_NETWORK", + "CAPSEM_INSPECT_BUILD_CACHE_DIR", + "CAPSEM_INSPECT_BUILD_REGISTRY_PORT", + "CAPSEM_INSPECT_BUILD_CA_PEM_FILE", +) + + +async def _expect_value_error(task_name: str, cfg: CapsemSandboxConfig, expected: str) -> None: + try: + await CapsemSandboxEnvironment.sample_init(task_name=task_name, config=cfg, metadata={}) + except ValueError as exc: + assert expected in str(exc), f"expected {expected!r} in {exc!r}" + else: + raise AssertionError(f"expected ValueError({expected!r}) for {task_name}") + + +async def _cleanup_sample(task: str, envs: dict[str, Any], hyp: Hypervisor, vm_id: str) -> None: + await CapsemSandboxEnvironment.sample_cleanup(task, None, envs, False) + await CapsemSandboxEnvironment.task_cleanup(task, None, True) + remaining_ids = {m.id for m in (await hyp.list()).sandboxes} + assert vm_id not in remaining_ids, f"{task} sample_cleanup leaked VM {vm_id}" + + +async def verify_host_build_workload_mode( + hyp: Hypervisor, + home_dir: Path, + verify_session_ledger: Callable[..., Awaitable[None]], + run_self_check: Callable[..., Awaitable[dict[str, bool | str]]] | None = None, +) -> None: + """Verify Compose `build:` + `Dockerfile` host build, refusals, cache reuse, and live VM.""" + _hb_fixture.require_docker() + base_tag = f"capsem-gate-base:{os.getpid()}" + _hb_fixture.load_base_image_into_docker(base_tag) + settings_path = home_dir / "settings.toml" + prev_settings = settings_path.read_text(encoding="utf-8") if settings_path.exists() else None + saved_env = {key: os.environ.get(key) for key in _ENV_KEYS} + + orig_build = image_build_mod.run_host_build + built_digests: list[str] = [] + + def _counting_build(*args: Any, **kwargs: Any) -> str: + digest = orig_build(*args, **kwargs) + built_digests.append(digest) + return digest + + with ( + tempfile.TemporaryDirectory(prefix="capsem-host-build-gate-") as tmp, + patch.object(image_build_mod, "run_host_build", _counting_build), + ): + root = Path(tmp) + compose_file, project, active_escape = _hb_fixture.write_dockerfile_and_context( + root, base_tag + ) + ca_file = root / "corp-ca.pem" + ca_key = root / "corp-ca.key" + cache_dir = root / "oci-cache" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=CapsemTestBuildCA", + "-keyout", + str(ca_key), + "-out", + str(ca_file), + ], + check=True, + capture_output=True, + timeout=15, + ) + reg_port = _hb_fixture.pick_registry_port() + + try: + for key in _ENV_KEYS: + os.environ.pop(key, None) + grant_self = HostBuildGrant(allowed_contexts=(str(project),), network="none") + for name, grant in (("gate_hb_off", None), ("gate_hb_self", grant_self)): + cfg_ref = CapsemSandboxConfig(compose_file=str(compose_file), host_build=grant) + await _expect_value_error(name, cfg_ref, "CAPSEM_INSPECT_HOST_BUILD") + + os.environ["CAPSEM_INSPECT_HOST_BUILD"] = "1" + os.environ["CAPSEM_INSPECT_ALLOWED_HOST_PATHS"] = f"{project},{ca_file}" + os.environ["CAPSEM_INSPECT_BUILD_NETWORK"] = "none" + os.environ["CAPSEM_INSPECT_BUILD_CACHE_DIR"] = str(cache_dir) + os.environ["CAPSEM_INSPECT_BUILD_REGISTRY_PORT"] = str(reg_port) + os.environ["CAPSEM_INSPECT_BUILD_CA_PEM_FILE"] = str(ca_file) + + grant_ctx = HostBuildGrant(allowed_contexts=(str(root / "outside"),), network="none") + grant_net = HostBuildGrant(allowed_contexts=(str(project),), network="default") + for name, grant, expected in ( + ("gate_hb_symlink", None, "active_escape"), + ("gate_hb_widen_ctx", grant_ctx, "CAPSEM_INSPECT_ALLOWED_HOST_PATHS"), + ("gate_hb_widen_net", grant_net, "CAPSEM_INSPECT_BUILD_NETWORK"), + ): + cfg_ref = CapsemSandboxConfig(compose_file=str(compose_file), host_build=grant) + await _expect_value_error(name, cfg_ref, expected) + active_escape.unlink(missing_ok=True) + + repo_ref = f"127.0.0.1:{reg_port}/inspect-capsem/build" + home_dir.mkdir(parents=True, exist_ok=True) + _hb_fixture.grant_in_settings(home_dir, repo_ref) + cfg = CapsemSandboxConfig(compose_file=str(compose_file), cpu_count=2, ram_gb=2) + envs = await CapsemSandboxEnvironment.sample_init("gate_hb_live", cfg, {}) + vm_id = "" + try: + assert len(built_digests) == 1 + assert cfg.working_dir is None and cfg.user is None + sb = envs["default"] + assert isinstance(sb, CapsemSandboxEnvironment) + vm_id = sb.vm_id + assert sb.execution_mode == "container" + ca_guest = await sb.read_file("/usr/local/share/ca-certificates/capsem-ca.crt") + assert ca_guest.strip() == ca_file.read_text(encoding="utf-8").strip() + tools_bin = f"{INSPECT_SANDBOX_TOOLS_ONEDIR_PATH}/inspect-sandbox-tools" + probe = ( + 'pwd && id -u && id -g && echo "$APP_MODE" && cat stage1.txt && ' + "cat app.txt && stat -c %a:%u:%g app.txt && cat kept.txt && " + "cat unpacked/from_tar.txt && cat ca_verified.txt && " + f"test -x {INSPECT_SANDBOX_TOOLS_GUEST_PATH} && " + f"test -x {tools_bin} && " + f"stat -c %a:%u:%g {INSPECT_SANDBOX_TOOLS_GUEST_PATH} && " + "test ! -e /opt/app/sub/ignored.txt && test ! -e /ignored.txt && " + "test ! -e /ignored_symlink && echo ignore-ok && " + "grep -q 'BEGIN CERTIFICATE' /usr/local/share/capsem/ca-bundle.crt && " + "grep -q 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt && " + "echo ca-ok" + ) + res = await sb.exec(["/bin/sh", "-c", probe]) + assert res.returncode == 0, f"probe failed: {res.stdout}\n{res.stderr}" + expected_lines = ( + "/opt/app/sub\n1000\n1000\nproduction\nstage1:from-compose-arg\n" + "exec-form-ok\nheredoc-run-ok\nheredoc-copy-ok\n640:1000:1000\n" + "kept-ok\ntar-extracted\nca-trust-ok\n700:1000:1000\nignore-ok\nca-ok" + ).splitlines() + assert res.stdout.strip().splitlines() == expected_lines + await sb.write_file("roundtrip.txt", "a\n") + append_res = await sb.exec(["/bin/sh", "-c", "echo b >> roundtrip.txt"]) + assert append_res.returncode == 0, append_res.stderr + assert await sb.read_file("roundtrip.txt") == "a\nb\n" + await sb.exec(["rm", "-f", "roundtrip.txt"]) + try: + await sb.exec(["id", "-u"], user="root") + except PermissionError: + pass + else: + raise AssertionError("expected PermissionError for user='root' in container") + await verify_session_ledger(hyp, vm_id, expected_target="workload", marker="ca-ok") + if run_self_check is not None: + sc_res = await run_self_check( + sb, + skip={"test_read_and_write_large_file_binary", "test_exec_as_user"}, + ) + failures = {k: v for k, v in sc_res.items() if v is not True} + assert not failures, ( + f"Live self_check (non-root container) failures: {failures}" + ) + finally: + await _cleanup_sample("gate_hb_live", envs, hyp, vm_id) + + _hb_fixture.restore_settings(settings_path, prev_settings) + _hb_fixture.grant_in_settings(home_dir, f"{repo_ref}@sha256:{built_digests[0]}") + envs2 = await CapsemSandboxEnvironment.sample_init("gate_hb_cached", cfg, {}) + vm2_id = "" + try: + assert len(built_digests) == 1, "expected cache hit without second docker build" + sb2 = envs2["default"] + assert isinstance(sb2, CapsemSandboxEnvironment) + vm2_id = sb2.vm_id + res2 = await sb2.exec(["cat", "kept.txt"]) + assert res2.returncode == 0 and res2.stdout.strip() == "kept-ok" + finally: + await _cleanup_sample("gate_hb_cached", envs2, hyp, vm2_id) + print("INSPECT_CAPSEM_HOST_BUILD_ACCEPTANCE_OK") + finally: + _hb_fixture.restore_settings(settings_path, prev_settings) + for key, val in saved_env.items(): + if val is None: + os.environ.pop(key, None) + else: + os.environ[key] = val + subprocess.run(["docker", "rmi", "-f", base_tag], check=False, capture_output=True) diff --git a/integrations/inspect-ai/tests/host_build_fixture.py b/integrations/inspect-ai/tests/host_build_fixture.py new file mode 100644 index 000000000..9e0843bfa --- /dev/null +++ b/integrations/inspect-ai/tests/host_build_fixture.py @@ -0,0 +1,161 @@ +"""Fixture helpers for hermetic `compose.yaml` `build:` / `Dockerfile` live acceptance.""" + +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import platform +import shutil +import socket +import subprocess +import tarfile +from pathlib import Path +from typing import Any + + +def load_sibling_module(filename: str) -> Any: + """Load a sibling test module by filename without mutating `sys.modules['tests']`.""" + path = Path(__file__).resolve().with_name(filename) + spec = importlib.util.spec_from_file_location(f"_capsem_sibling_{path.stem}", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +try: + import tests.oci_workload_fixture as _oci_fixture +except ImportError: + _oci_fixture = load_sibling_module("oci_workload_fixture.py") + +hermetic_oci_workload_image = _oci_fixture.hermetic_oci_workload_image + + +def require_docker() -> None: + """Ensure the `docker` CLI and daemon are available, or fail loudly.""" + if shutil.which("docker") is None: + raise RuntimeError("docker CLI is required on PATH for host-build live acceptance") + proc = subprocess.run(["docker", "info"], capture_output=True, text=True, timeout=15) + if proc.returncode != 0: + raise RuntimeError(f"docker daemon required for host-build acceptance: {proc.stderr}") + + +def load_base_image_into_docker(base_tag: str) -> None: + """Build a minimal busybox OCI tar from the guest initrd and load it into local Docker.""" + busybox = _oci_fixture._extract_busybox_from_initrd(_oci_fixture._find_initrd_path()) + raw_tar = _oci_fixture._build_rootfs_tar_bytes(busybox) + oci_arch = "arm64" if platform.machine().lower() in ("aarch64", "arm64") else "amd64" + env_path = "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + inner_cfg = { + "Env": [env_path], + "Cmd": ["/bin/sh", "-c", "sleep 3600"], + "WorkingDir": "/", + } + rootfs = { + "type": "layers", + "diff_ids": ["sha256:" + hashlib.sha256(raw_tar).hexdigest()], + } + config_doc = { + "architecture": oci_arch, + "os": "linux", + "config": inner_cfg, + "rootfs": rootfs, + } + config_bytes = json.dumps(config_doc, sort_keys=True).encode("utf-8") + manifest_doc = [{"Config": "config.json", "RepoTags": [base_tag], "Layers": ["layer.tar"]}] + manifest_bytes = json.dumps(manifest_doc).encode("utf-8") + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w") as tf: + for name, data in ( + ("config.json", config_bytes), + ("layer.tar", raw_tar), + ("manifest.json", manifest_bytes), + ): + info = tarfile.TarInfo(name=name) + info.size = len(data) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(data)) + subprocess.run(["docker", "load"], input=buf.getvalue(), check=True, capture_output=True) + + +def write_dockerfile_and_context(root: Path, base_tag: str) -> tuple[Path, Path, Path]: + """Materialize a multi-stage Dockerfile, compose.yaml, tar archive, and symlink escape.""" + outside = root / "outside" + outside.mkdir(parents=True, exist_ok=True) + outside_secret = outside / "secret.txt" + outside_secret.write_text("host-secret\n", encoding="utf-8") + + project = root / "project" + sub_dir = project / "sub" + sub_dir.mkdir(parents=True, exist_ok=True) + + active_escape = project / "active_escape" + active_escape.symlink_to(outside_secret) + + (project / ".dockerignore").write_text("ignored.txt\nignored_symlink\n", encoding="utf-8") + (project / "ignored.txt").write_text("should-not-enter-context\n", encoding="utf-8") + (project / "ignored_symlink").symlink_to(outside_secret) + (sub_dir / "kept.txt").write_text("kept-ok\n", encoding="utf-8") + + tar_buf = io.BytesIO() + with tarfile.open(fileobj=tar_buf, mode="w:gz") as tf: + payload = b"tar-extracted\n" + info = tarfile.TarInfo(name="unpacked/from_tar.txt") + info.size = len(payload) + info.mode = 0o644 + tf.addfile(info, io.BytesIO(payload)) + (sub_dir / "archive.tar.gz").write_bytes(tar_buf.getvalue()) + + compose_file = project / "compose.yaml" + compose_file.write_text( + "services:\n default:\n build:\n context: .\n dockerfile: Dockerfile\n" + " target: final\n args:\n GREETING: from-compose-arg\n", + encoding="utf-8", + ) + (project / "Dockerfile").write_text( + f"FROM {base_tag} AS builder\nARG GREETING=default-greeting\n" + 'RUN echo "stage1:${GREETING}" > /stage1.txt\n' + 'RUN ["/bin/sh", "-c", "echo exec-form-ok >> /stage1.txt"]\n' + "RUN <> /stage1.txt\nEOF\n" + "COPY < /opt/app/sub/ca_verified.txt\n" + 'USER 1000:1000\nENTRYPOINT ["/bin/sh", "-c"]\nCMD ["sleep 3600"]\n', + encoding="utf-8", + ) + return compose_file, project, active_escape + + +def pick_registry_port() -> int: + """Select loopback port 5055 when free, or an ephemeral loopback port if 5055 is busy.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", 5055)) + except OSError: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def restore_settings(settings_path: Path, prev: str | None) -> None: + """Restore or remove `settings.toml` after an acceptance test.""" + if prev is None: + settings_path.unlink(missing_ok=True) + else: + settings_path.write_text(prev, encoding="utf-8") + + +def grant_in_settings(home_dir: Path, reference: str) -> None: + """Delegate `[images]` admission update in `settings.toml` to the OCI fixture helper.""" + _oci_fixture._grant_in_settings(home_dir, reference) diff --git a/integrations/inspect-ai/tests/live_acceptance.py b/integrations/inspect-ai/tests/live_acceptance.py index a3f197d70..6abc180db 100644 --- a/integrations/inspect-ai/tests/live_acceptance.py +++ b/integrations/inspect-ai/tests/live_acceptance.py @@ -8,7 +8,7 @@ import sqlite3 import tempfile from pathlib import Path -from typing import Any, cast +from unittest.mock import patch import inspect_ai.util._sandbox.self_check as inspect_self_check import inspect_capsem.sandbox as sb_mod @@ -19,25 +19,21 @@ from inspect_ai.solver import Generate, Solver, TaskState, solver from inspect_ai.util import SandboxEnvironmentSpec, sandbox from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment -from inspect_capsem._controller import ( - SdkCapsemController, - _is_managed_vm, - _managed_vm_labels, -) +from inspect_capsem._controller import SdkCapsemController, _is_managed_vm, _managed_vm_labels try: + from tests.host_build_acceptance import verify_host_build_workload_mode from tests.oci_workload_fixture import hermetic_oci_workload_image except ImportError: - import importlib.util + import importlib.util as _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 + _hb_path = Path(__file__).resolve().with_name("host_build_acceptance.py") + _hb_spec = _importlib_util.spec_from_file_location("_capsem_hb_acceptance", _hb_path) + assert _hb_spec is not None and _hb_spec.loader is not None + _hb_mod = _importlib_util.module_from_spec(_hb_spec) + _hb_spec.loader.exec_module(_hb_mod) + verify_host_build_workload_mode = _hb_mod.verify_host_build_workload_mode + hermetic_oci_workload_image = _hb_mod._hb_fixture.hermetic_oci_workload_image def _capsem_home_dir() -> Path: @@ -49,14 +45,13 @@ async def _verify_session_ledger( hyp: Hypervisor, vm_id: str, *, expected_target: str, marker: str ) -> None: history = await hyp.vm(id=vm_id).history(layer=models.HistoryLayerFilter.EXEC, limit=50) - assert history.total >= 1 - assert any(marker in entry.command for entry in history.commands) + assert history.total >= 1 and any(marker in entry.command for entry in history.commands) home = _capsem_home_dir() - candidates = [ + candidates = ( home / "run" / "sessions" / vm_id / "session.db", home / "sessions" / vm_id / "session.db", - ] + ) db_path = next((p for p in candidates if p.is_file()), candidates[0]) assert db_path.is_file(), f"expected session ledger at {db_path}" with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) as conn: @@ -76,9 +71,8 @@ async def _run_inspect_self_check( for name in inspect_self_check.__all__: if skip and name in skip: continue - fn = getattr(inspect_self_check, name) try: - await fn(env) + await getattr(inspect_self_check, name)(env) results[name] = True except Exception as exc: results[name] = f"{type(exc).__name__}: {exc}" @@ -113,52 +107,32 @@ async def _verify_eval_task(hyp: Hypervisor, config: CapsemSandboxConfig, marker 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={}, - ) + ca_ctrl = functools.partial(SdkCapsemController, registry_ca_pem=ca_pem) + with patch.object(sb_mod, "SdkCapsemController", ca_ctrl): + cfg = CapsemSandboxConfig(image=image_ref, cpu_count=2, ram_gb=2, working_dir="/workspace") + envs = await CapsemSandboxEnvironment.sample_init("gate_container_acceptance", cfg, {}) vm_id = "" try: - sb_env = envs["default"] - assert isinstance(sb_env, CapsemSandboxEnvironment) - sb, vm_id = sb_env, sb_env.vm_id + sb = envs["default"] + assert isinstance(sb, CapsemSandboxEnvironment) + vm_id = sb.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" - ) + assert marker_res.returncode == 0 + assert marker_res.stdout.strip() == "capsem-hermetic-oci-fixture" + ok_mark = "INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK" + exec_res = await sb.exec(["echo", ok_mark]) + assert exec_res.returncode == 0 and exec_res.stdout.strip() == ok_mark 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", - ) + await _verify_session_ledger(hyp, vm_id, expected_target="workload", marker=ok_mark) 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 + "gate_container_acceptance", None, envs, False ) + await CapsemSandboxEnvironment.task_cleanup("gate_container_acceptance", None, True) assert vm_id not in {m.id for m in (await hyp.list()).sandboxes}, ( f"container sample_cleanup leaked VM {vm_id}" ) @@ -168,12 +142,10 @@ async def _verify_container_workload_mode(hyp: Hypervisor, image_ref: str, ca_pe "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 and hermetic OCI container acceptance test for the gate VM lane.""" + """VM, hermetic OCI container, and hermetic host-build acceptance test for the gate VM lane.""" hyp = Hypervisor.connect() persistent_vm = await hyp.create( name=f"foreign-persistent-{os.getpid()}", @@ -184,20 +156,12 @@ async def run_live_vm_sandbox_acceptance() -> None: orphan_ctrl = SdkCapsemController(hypervisor=hyp) orphan_id: str | None = None try: - envs = await CapsemSandboxEnvironment.sample_init( - task_name="gate_vm_acceptance", - config=CapsemSandboxConfig( - cpu_count=2, - ram_gb=2, - working_dir="/workspace", - ), - metadata={}, - ) + cfg = CapsemSandboxConfig(cpu_count=2, ram_gb=2, working_dir="/workspace") + envs = await CapsemSandboxEnvironment.sample_init("gate_vm_acceptance", cfg, {}) vm_id = "" try: - sb_env = envs["default"] - assert isinstance(sb_env, CapsemSandboxEnvironment) - sb = sb_env + sb = envs["default"] + assert isinstance(sb, CapsemSandboxEnvironment) vm_id = sb.vm_id exec_res = await sb.exec(["echo", "INSPECT_CAPSEM_VM_ACCEPTANCE_OK"]) assert exec_res.returncode == 0, exec_res.stderr @@ -220,10 +184,7 @@ async def run_live_vm_sandbox_acceptance() -> None: await sb.write_file("/workspace/roundtrip.bin", payload) assert await sb.read_file("/workspace/roundtrip.bin", text=False) == payload await _verify_session_ledger( - hyp, - sb.vm_id, - expected_target="vm", - marker="INSPECT_CAPSEM_VM_ACCEPTANCE_OK", + hyp, vm_id, expected_target="vm", marker="INSPECT_CAPSEM_VM_ACCEPTANCE_OK" ) # Skip test_read_and_write_large_file_binary (>100 MiB allocation/transfer) in live # gate runs; Capsem's regular-file byte cap and OutputLimitExceededError path are @@ -235,17 +196,8 @@ async def run_live_vm_sandbox_acceptance() -> None: assert not failures, f"Live self_check (vm) failures: {failures}" await sb._controller.stop_vm("nonexistent-vm-404") finally: - await CapsemSandboxEnvironment.sample_cleanup( - task_name="gate_vm_acceptance", - config=None, - environments=envs, - interrupted=False, - ) - await CapsemSandboxEnvironment.task_cleanup( - task_name="gate_vm_acceptance", - config=None, - cleanup=True, - ) + await CapsemSandboxEnvironment.sample_cleanup("gate_vm_acceptance", None, envs, False) + await CapsemSandboxEnvironment.task_cleanup("gate_vm_acceptance", None, True) assert vm_id not in {m.id for m in (await hyp.list()).sandboxes}, ( f"sample_cleanup leaked VM {vm_id}" ) @@ -259,6 +211,10 @@ async def run_live_vm_sandbox_acceptance() -> None: with hermetic_oci_workload_image(_capsem_home_dir()) as (image_ref, ca_pem): await _verify_container_workload_mode(hyp, image_ref, ca_pem) + await verify_host_build_workload_mode( + hyp, _capsem_home_dir(), _verify_session_ledger, _run_inspect_self_check + ) + 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/test_compose_config.py b/integrations/inspect-ai/tests/test_compose_config.py index 2a48d3be1..18c85a00a 100644 --- a/integrations/inspect-ai/tests/test_compose_config.py +++ b/integrations/inspect-ai/tests/test_compose_config.py @@ -9,11 +9,15 @@ import pytest from inspect_ai.util import ComposeConfig, ComposeService from inspect_capsem import CapsemSandboxConfig, CapsemSandboxEnvironment +from inspect_capsem.containers import HostBuildGrant from .helpers import LocalFakeCapsemController -def test_coerce_config_variants(tmp_path: Path) -> None: +def test_coerce_config_variants(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_HOST_BUILD", raising=False) 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") @@ -22,8 +26,16 @@ def test_coerce_config_variants(tmp_path: Path) -> None: plain = CapsemSandboxConfig(image="x") assert coerce(plain) is plain assert coerce(CapsemSandboxConfig(compose_file="")).compose_file == "" - with pytest.raises(ValueError, match="Dockerfile / Containerfile builds"): + with pytest.raises(ValueError, match="disabled by default"): + coerce("Dockerfile") + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + with pytest.raises(ValueError, match="allowed_host_paths"): coerce("Dockerfile") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + df_coerced = coerce("Dockerfile") + assert df_coerced.execution_mode == "container" and df_coerced.build is not None + assert CapsemSandboxEnvironment.config_deserialize(df_coerced.model_dump()) == df_coerced + assert CapsemSandboxConfig.model_validate_json(df_coerced.model_dump_json()) == df_coerced assert coerce(str(compose)).image == "ubuntu:24.04" absent = str(tmp_path / "absent.yml") with pytest.raises(FileNotFoundError, match="Compose file not found"): @@ -53,7 +65,9 @@ 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.chdir(tmp_path) monkeypatch.delenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", raising=False) + monkeypatch.delenv("CAPSEM_INSPECT_HOST_BUILD", raising=False) host_dir = tmp_path / "mounted_dir" host_dir.mkdir() (host_dir / "file.txt").write_text("hello", encoding="utf-8") @@ -111,10 +125,42 @@ def test_compose_file_config_applies_service_fields( ) assert coerced_cc.to_container_spec().working_dir_explicit is True + for forbidden_direct in ( + {"build": {"context": "/etc", "dockerfile": "/etc/passwd"}}, + {"build": str(Path.home())}, + {"dockerfile": "/etc/hostname"}, + {"image": "alpine:3.20", "registry_ca_pem": "-----BEGIN CERTIFICATE-----"}, + ): + with pytest.raises(ValueError): + compose_mod.coerce_config(forbidden_direct) + + df_file = tmp_path / "Dockerfile" + df_file.write_text("FROM alpine:3.20\nWORKDIR /app/work\nUSER 1002:1002\n", encoding="utf-8") + grant = HostBuildGrant(allowed_contexts=(str(tmp_path),)) + with pytest.raises(ValueError, match="CAPSEM_INSPECT_HOST_BUILD"): + compose_mod.coerce_config({"dockerfile": str(df_file), "host_build": grant}) + + monkeypatch.setenv("CAPSEM_INSPECT_HOST_BUILD", "1") + monkeypatch.setenv("CAPSEM_INSPECT_ALLOWED_HOST_PATHS", str(tmp_path)) + direct_df = compose_mod.coerce_config({"dockerfile": str(df_file), "host_build": grant}) + assert direct_df.build is not None and direct_df.execution_mode == "container" + assert CapsemSandboxEnvironment.config_deserialize(direct_df.model_dump()) == direct_df + assert CapsemSandboxConfig.model_validate_json(direct_df.model_dump_json()) == direct_df + cc_build = compose_mod.coerce_config( + ComposeConfig(services={"default": ComposeService(build=str(tmp_path))}) + ) + assert cc_build.build is not None and cc_build.execution_mode == "container" + assert CapsemSandboxEnvironment.config_deserialize(cc_build.model_dump()) == cc_build + assert CapsemSandboxConfig.model_validate_json(cc_build.model_dump_json()) == cc_build + + expected_roots = (str(tmp_path.resolve()),) 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"): + coerced_df = compose_mod.coerce_config(df_str) + assert coerced_df.execution_mode == "container" and coerced_df.build is not None + assert coerced_df.to_container_spec().allowed_host_paths == expected_roots + assert compose_mod.coerce_config({"dockerfile": str(df_file)}).build is not None + assert compose_mod.coerce_config({"build": str(tmp_path)}).build is not None + for bad_dict_key in ("build_context", "build_args", "build_target", "dockerfile_inline"): with pytest.raises(ValueError, match=bad_dict_key): compose_mod.coerce_config({bad_dict_key: "x"}) diff --git a/integrations/inspect-ai/tests/test_config.py b/integrations/inspect-ai/tests/test_config.py index fc3f4be40..5982c21b2 100644 --- a/integrations/inspect-ai/tests/test_config.py +++ b/integrations/inspect-ai/tests/test_config.py @@ -11,8 +11,9 @@ 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._controller import SdkCapsemController from inspect_capsem._exec import _format_exec_command +from inspect_capsem._users import is_root_user_spec from inspect_capsem.containers.compose_fields import ( _is_host_path_allowed, extract_capsem_compose_fields, diff --git a/integrations/inspect-ai/tests/test_containers_boundary.py b/integrations/inspect-ai/tests/test_containers_boundary.py index 749498a51..18692652d 100644 --- a/integrations/inspect-ai/tests/test_containers_boundary.py +++ b/integrations/inspect-ai/tests/test_containers_boundary.py @@ -11,7 +11,8 @@ from inspect_capsem.containers.spec import ContainerSpec CONTAINERS_DIR = Path(__file__).resolve().parents[1] / "inspect_capsem" / "containers" -ALLOWED_PREFIX = ("inspect_capsem.containers",) +USERS_FILE = Path(__file__).resolve().parents[1] / "inspect_capsem" / "_users.py" +ALLOWED_PREFIX = ("inspect_capsem.containers", "inspect_capsem._users") def _imported_modules(py_file: Path) -> set[str]: @@ -33,6 +34,8 @@ def test_containers_package_has_no_inspect_or_parent_imports() -> None: 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}" + for mod in _imported_modules(USERS_FILE): + assert not mod.startswith(("inspect_ai", "inspect_capsem")), f"_users.py imported {mod!r}" def test_vm_mode_does_not_import_containers_at_top_level() -> None: diff --git a/tests/helpers/sdk_packages.py b/tests/helpers/sdk_packages.py index af32e87b0..1b95d6f49 100644 --- a/tests/helpers/sdk_packages.py +++ b/tests/helpers/sdk_packages.py @@ -51,7 +51,11 @@ def python_gateway( assert not work.resolve().is_relative_to(root.resolve()) prefix = work / "runtime" python = prefix / "bin/python" - _run(["uv", "venv", "--offline", "--python", sys.executable, str(prefix)], work, environment) + _run( + ["uv", "venv", "--offline", "--python", sys.executable, str(prefix)], + work, + environment, + ) _run(["uv", "pip", "install", "--offline", "--python", str(python), str(archive)], work, environment) payload = work / "payload.py" @@ -100,12 +104,15 @@ def typescript_gateway( with tempfile.TemporaryDirectory(prefix="braavos-npm-") as temporary: work = Path(temporary) assert not work.resolve().is_relative_to(root.resolve()) - (work / "package.json").write_text(json.dumps({"name": "braavos-consumer", "private": True})) + pkg_doc = {"name": "braavos-consumer", "private": True} + (work / "package.json").write_text(json.dumps(pkg_doc)) receipt = work / "payload.json" receipt.write_text(json.dumps({"archiveSha256": digest, "files": files})) # Resolve exclusively from the clean consumer. The gate owns cache # prewarming; acceptance cannot fetch dependencies or run build hooks. - clean = {key: value for key, value in environment.items() if key not in {"NODE_PATH", "NODE_OPTIONS"}} + clean = { + k: v for k, v in environment.items() if k not in {"NODE_PATH", "NODE_OPTIONS"} + } _run(["npm", "install", "--offline", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", str(archive)], work, clean) payload = work / "payload.mjs" @@ -113,7 +120,11 @@ def typescript_gateway( shutil.copyfile(project / "tools/image-package-acceptance.mjs", payload) shutil.copyfile(project / "tools" / probe, gateway) report = work / "acceptance.json" - result = _run(["node", str(payload), str(root), str(archive), str(report), str(receipt)], work, clean) + result = _run( + ["node", str(payload), str(root), str(archive), str(report), str(receipt)], + work, + clean, + ) assert "SDK_IMAGE_PACKAGE_ACCEPTANCE_OK" in result verified = json.loads(report.read_text()) assert verified["ok"] and verified["sha256"] == digest @@ -158,18 +169,24 @@ def inspect_ai_gateway( ], work, environment, ) - _run( - ["uv", "pip", "install", "--offline", "--no-deps", "--python", str(python), str(sdk_wheel), str(archive)], - work, environment, - ) + pip_cmd = [ + "uv", "pip", "install", "--offline", "--no-deps", + "--python", str(python), str(sdk_wheel), str(archive), + ] + _run(pip_cmd, work, environment) payload = work / "payload.py" gateway = work / "gateway.py" tests_dir = root / settings["tests"] for name, destination in (("image_package_acceptance.py", payload), (probe, gateway)): shutil.copyfile(tests_dir / name, destination) - fixture_helper = tests_dir / "oci_workload_fixture.py" - if fixture_helper.is_file(): - shutil.copyfile(fixture_helper, work / "oci_workload_fixture.py") + for helper_name in ( + "oci_workload_fixture.py", + "host_build_fixture.py", + "host_build_acceptance.py", + ): + helper_path = tests_dir / helper_name + if helper_path.is_file(): + shutil.copyfile(helper_path, work / helper_name) report = work / "payload.json" result = _run([str(python), "-I", str(payload), "--archive", str(archive), "--source-root", str(root), "--output", str(report)], work, environment) diff --git a/tests/ironbank/test_sdk_live.py b/tests/ironbank/test_sdk_live.py index 824d3cbd7..c55701c01 100644 --- a/tests/ironbank/test_sdk_live.py +++ b/tests/ironbank/test_sdk_live.py @@ -98,6 +98,7 @@ def test_inspect_ai_live_vm_sandbox_acceptance() -> None: ) assert "SDK_IMAGE_PACKAGE_ACCEPTANCE_OK" in output assert "INSPECT_CAPSEM_CONTAINER_ACCEPTANCE_OK" in output + assert "INSPECT_CAPSEM_HOST_BUILD_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 12c688c15..d259ff413 100644 --- a/web/docs/src/content/docs/usage/inspect-ai.md +++ b/web/docs/src/content/docs/usage/inspect-ai.md @@ -54,21 +54,39 @@ def my_eval() -> Task: ) ``` -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"`). +A string sandbox config is also accepted: a `compose.yaml` / `docker-compose.yml` path, a `Dockerfile` / `Containerfile` path, or a pre-built OCI image reference (`"python:3.12-slim"`). ## `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. +- **`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`, `image`, `dockerfile`, or `build` 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`). +- **Supported service fields**: `image`, `build` / `dockerfile`, `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`. +- **Unsupported**: 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`. + +### Evaluator-granted host image builds (`Compose build:` & `Dockerfile`) + +Host-assisted `Dockerfile` and Compose `build:` execution is **opt-in and disabled by default** (permission is never inferred from the presence of a `Dockerfile` or `build:` stanza). The evaluator process environment is the root authority (`CAPSEM_INSPECT_HOST_BUILD=1` and `CAPSEM_INSPECT_ALLOWED_HOST_PATHS`), and task config (`CapsemSandboxConfig(host_build=HostBuildGrant(...))`) can only narrow — never widen — those grants. When granted, `inspect_capsem` builds a Linux OCI image on the host (`--network none` and an isolated empty `DOCKER_CONFIG` by default), ingests the `docker image save` archive into a local content-addressed blob cache, and serves it over a loopback HTTPS OCI v2 registry (`127.0.0.1:5055` by default) with a per-VM `[images]` `ca_pem` trust anchor passed to `capsem-service`. + +> **Evaluator environment authority (`CAPSEM_INSPECT_HOST_BUILD` & `HostBuildGrant`):** `Dockerfile` `RUN` instructions execute inside `docker build` on the **host Docker daemon** before the built image is pulled into the Capsem micro-VM by digest (`127.0.0.1:/inspect-capsem/build@sha256:`). Without `CAPSEM_INSPECT_HOST_BUILD=1` in the evaluator environment, any `Dockerfile` or Compose `build:` config fails fast with `ValueError` (even if `host_build` is set on the task). A task passing `host_build=False` or `HostBuildGrant(enabled=False)` disables host builds for that task. +> +> **Explicit context & symlink containment (`CAPSEM_INSPECT_ALLOWED_HOST_PATHS` & `HostBuildGrant.allowed_contexts`):** Build context roots are **never** inferred from a `compose.yaml` file's parent directory. The evaluator must allowlist build context and `Dockerfile` directories via `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` (optionally narrowed per task via `allowed_host_paths` or `HostBuildGrant(allowed_contexts=(...,))`). Non-`.dockerignore`d symlinks inside the build context whose `os.path.realpath` escapes the context root are rejected before `docker build` runs, and mounting `docker.sock` into a sandbox is forbidden. +> +> **Network, CA trust, credentials, and cache integrity (`CAPSEM_INSPECT_BUILD_CACHE_DIR`):** Host builds default to `--network none` (operator ceiling `CAPSEM_INSPECT_BUILD_NETWORK=none|default`, optionally narrowed by `HostBuildGrant(network="none")`; `--network host` is always refused) and an isolated temporary `DOCKER_CONFIG` (`{"auths":{}}`) so ambient `~/.docker/config.json` credentials are never read unless `CAPSEM_INSPECT_BUILD_DOCKER_CONFIG` is explicitly set under an allowed host path. When `CAPSEM_INSPECT_BUILD_CA_PEM_FILE` (for example `~/.capsem/ca.crt`) is set under `CAPSEM_INSPECT_ALLOWED_HOST_PATHS`, `inspect_capsem` stages `.capsem-ca.crt` (the Capsem CA only, copied to `/usr/local/share/ca-certificates/capsem-ca.crt` for `update-ca-certificates` and `NODE_EXTRA_CA_CERTS`) and `.capsem-ca-bundle.crt` (the full root bundle from `CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE` plus the Capsem CA, copied to `/usr/local/share/capsem/ca-bundle.crt` for `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `PIP_CERT`, `CURL_CA_BUNDLE`, and `GIT_SSL_CAINFO`, plus `UV_NATIVE_TLS=1`), runs the distro CA updater and NSS `certutil` (`$HOME/.pki/nssdb`) before the first shell-form `RUN`, and seeds `$HOME/.pki/nssdb` at container startup (while `capsem-init` bind-mounts `/etc/ssl/certs/ca-certificates.crt` and sets the runtime TLS environment inside the VM). Networked builds (`--network default`) with `CAPSEM_INSPECT_BUILD_CA_PEM_FILE` require `CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE` (for example `/etc/ssl/certs/ca-certificates.crt`) so public root CAs remain trusted during `docker build`. Cache keys hash all regular files in the build context (plus non-`.dockerignore`d symlinks), `Dockerfile`, `args`, `target`, `network`, target platform, and the SHA-256 fingerprint of any configured build CA files (so CA rotation automatically invalidates cached builds). On cache hits, manifest, config, and layer blobs are verified by SHA-256; any corrupted blob and stale cache-key pointer are unlinked before rebuilding. Inside the VM, `capsem-init` applies the built OCI image's `Config.WorkingDir` (probed by `inspect_capsem` when `working_dir` is not explicitly set) and `Config.User` at container start. + +Host-side image builds require the `docker` CLI (with BuildKit enabled, the default since Docker 23.0; `DOCKER_BUILDKIT=0` is refused) and `openssl` on `PATH`, plus an explicit image source admission rule in `~/.capsem/settings.toml` (matching the loopback registry `host:port`, `127.0.0.1:5055` by default) before starting `capsem-service`: + +```toml +[images] +sources = ["127.0.0.1:5055"] +admit = ["127.0.0.1:5055/inspect-capsem/build"] +``` ## Configuration reference (`CapsemSandboxConfig`) @@ -80,12 +98,15 @@ In `container` mode, `inspect-capsem-sandbox` parses single-service `compose.yam | `ram_gb` | `int` | `8` | Memory allocation in GiB for the Capsem 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 | +| `dockerfile` | `str \| None` | `None` | Path to a standalone `Dockerfile` / `Containerfile` (requires `CAPSEM_INSPECT_HOST_BUILD=1`) | +| `build` | `dict[str, Any] \| None` | `None` | Normalized Compose `build:` specification (requires `CAPSEM_INSPECT_HOST_BUILD=1`) | +| `host_build` | `HostBuildGrant \| bool \| None` | `None` | Task-level host-build policy narrowing `CAPSEM_INSPECT_HOST_BUILD` and `CAPSEM_INSPECT_ALLOWED_HOST_PATHS` | | `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) | +| `user` | `str \| None` | `None` (root in `vm`; image `USER` or root in `container`) | 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` | @@ -104,7 +125,16 @@ Gateway discovery follows the same precedence as the `capsem` CLI: 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. +- `CAPSEM_INSPECT_ALLOWED_HOST_PATHS`: Operator-owned list of host directory or file roots permitted for bind-mount sources and `Dockerfile` / `build:` contexts. +- `CAPSEM_INSPECT_HOST_BUILD`: Set to `1` (`true`) in the evaluator environment to authorize host-side `docker build` execution, or `0` (`false`) to deny (defaults to disabled). +- `CAPSEM_INSPECT_BUILD_NETWORK`: Operator ceiling for `docker build --network` (`none` or `default`; defaults to `none`; `host` is refused). +- `CAPSEM_INSPECT_BUILD_DOCKER_CONFIG`: Optional operator-allowlisted `docker --config` directory when pulling private base images during host builds. +- `CAPSEM_INSPECT_BUILD_CA_PEM_FILE`: Optional operator-allowlisted Capsem CA PEM file (for example `~/.capsem/ca.crt`) staged into `.capsem-ca.crt` during host builds (`HostBuildGrant(ca_pem=False)` disables per task). +- `CAPSEM_INSPECT_BUILD_CA_BUNDLE_FILE`: Optional operator-allowlisted base root CA bundle (for example `/etc/ssl/certs/ca-certificates.crt`; required when `CAPSEM_INSPECT_BUILD_CA_PEM_FILE` is combined with `--network default`) merged with the Capsem CA into `.capsem-ca-bundle.crt`. +- `CAPSEM_INSPECT_BUILD_TIMEOUT`: Timeout in seconds for host `docker build` and `docker image save` commands (defaults to `600`). +- `CAPSEM_INSPECT_BUILD_REGISTRY_PORT`: Loopback HTTPS OCI v2 registry port (defaults to `5055`; must match `sources` and `admit` under `[images]` in `~/.capsem/settings.toml`). +- `CAPSEM_INSPECT_BUILD_CACHE_DIR`: Directory storing cached OCI blobs and local TLS certificates (defaults to `~/.cache/capsem/inspect-oci-builds`). +- `CAPSEM_INSPECT_BUILD_PLATFORM`: Target platform passed to `docker build --platform` (defaults to `linux/arm64` on `aarch64`/`arm64` hosts and `linux/amd64` on `x86_64` hosts). - `INSPECT_CAPSEM_SANDBOX_TOOLS_PATH`: Optional host path override for the `inspect-sandbox-tools` binary baked into `/var/tmp/sandbox-services/inspect-sandbox-tools`.