Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down
10 changes: 10 additions & 0 deletions integrations/inspect-ai/inspect_capsem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@

from __future__ import annotations

from typing import Any

from inspect_capsem.config import CapsemSandboxConfig
from inspect_capsem.sandbox import CapsemSandboxEnvironment

__all__ = [
"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}")
6 changes: 2 additions & 4 deletions integrations/inspect-ai/inspect_capsem/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions integrations/inspect-ai/inspect_capsem/_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion integrations/inspect-ai/inspect_capsem/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions integrations/inspect-ai/inspect_capsem/_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions integrations/inspect-ai/inspect_capsem/_users.py
Original file line number Diff line number Diff line change
@@ -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"]
53 changes: 41 additions & 12 deletions integrations/inspect-ai/inspect_capsem/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)


Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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, ...] = ()
Expand All @@ -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():
Expand All @@ -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,
)
2 changes: 2 additions & 0 deletions integrations/inspect-ai/inspect_capsem/containers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -17,6 +18,7 @@
"ContainerCommandResult",
"ContainerController",
"ContainerSpec",
"HostBuildGrant",
"extract_capsem_compose_fields",
"extract_compose_fields",
"normalize_volumes",
Expand Down
Loading
Loading