diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fcbc25316..efd507346 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -105,7 +105,7 @@ jobs: - name: Install system dependencies (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + run: sudo apt-get update && sudo apt-get install -y libseccomp-dev protobuf-compiler - name: Install system dependencies (macOS) if: runner.os == 'macOS' @@ -121,6 +121,10 @@ jobs: # Only test boxlite-shared - boxlite requires libkrun/libgvproxy not available in CI run: cargo llvm-cov nextest -p boxlite-shared --lib --profile ci --lcov --output-path lcov.info + - name: Run guest unit tests + if: runner.os == 'Linux' + run: make test:unit:guest + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index f5625b51b..d30f72ea7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -2,6 +2,8 @@ Related design: [AutoPause / AutoResume / AutoDelete](./auto-pause-resume-design.md) +Container security design: [Linux capability API](./container-capabilities.md) + ## Overview BoxLite is an embeddable virtual machine runtime that follows the SQLite philosophy: a library that diff --git a/docs/architecture/container-capabilities.md b/docs/architecture/container-capabilities.md new file mode 100644 index 000000000..e65017631 --- /dev/null +++ b/docs/architecture/container-capabilities.md @@ -0,0 +1,142 @@ +# Linux capability API design + +Status: accepted + +## Decision + +BoxLite exposes a high-level delta policy rather than the OCI runtime's five +exact capability sets. The policy is grouped with the other expert-only +container settings instead of widening the top-level box API: + +| Surface | Add | Drop | +| --- | --- | --- | +| Rust, Python, REST | `advanced.capabilities.add` | `advanced.capabilities.drop` | +| Node.js / TypeScript | `advanced.capabilities.add` | `advanced.capabilities.drop` | +| Go | `AdvancedBoxOptions.SetCapabilities({Add: ...})` | `AdvancedBoxOptions.SetCapabilities({Drop: ...})` | +| C | `boxlite_advanced_options_set_capabilities_add` | `boxlite_advanced_options_set_capabilities_drop` | +| CLI | repeatable `--cap-add` | repeatable `--cap-drop` | + +Create inputs use that nested path. The CLI flags remain familiar Docker-style +shorthands and populate the nested object. + +The values remain strings rather than a public enum. Linux can add +capabilities independently of an SDK release, and the API host may run a +different kernel and OCI library from the BoxLite guest. Boundaries validate +the string shape; the guest that owns the OCI runtime validates support. + +Names are case-insensitive, may omit `CAP_`, and are deduplicated as sets. +Empty lists preserve BoxLite's 14-capability baseline. Resolution follows +Moby and Apple container: + +1. With `add=["ALL"]`, start from every capability supported by the guest + and remove explicitly named drops. Named drops win in this branch. +2. Otherwise, with `drop=["ALL"]`, keep only explicitly named additions. +3. Otherwise, start from the baseline, apply drops, then apply additions. + A named addition therefore wins a named conflict. + +Adding capabilities weakens the container boundary; `SYS_ADMIN` and `ALL` are +especially broad. Prefer `drop=["ALL"]` plus only the minimum additions a +workload needs. BoxLite's VM boundary remains separate, but it is not a reason +to grant unnecessary privilege inside the guest. + +The resolved set populates OCI bounding, effective, and permitted sets for +init and every later exec. Inheritable and ambient remain absent. They have +different privilege propagation semantics and require a separate, explicit +security design if BoxLite ever exposes them. + +Internally, the guest resolves the two input lists once into a `CapabilitySet`. +That facade owns parsing, default policy, `ALL` precedence, canonical names for +libcontainer, and OCI set construction. Downstream init/exec code carries only +the resolved type and cannot reinterpret the policy. + +## Compatibility and rollout + +Every versioned boundary negotiates capability support before a policy can be +silently dropped: + +- A remote SDK re-reads `linux_capabilities_enabled` from `GET /v1/config` + (uncached) immediately before creating a box with a custom policy, so a + server rollback cannot be masked by a stale discovery cache. +- A BoxLite host requires the guest to report version 0.9.8 or newer from + Ping before sending the nested policy. Guest rootfs images are cached per + version and reused, so an older guest can outlive its release; it would + decode the new field as unknown proto and drop it. + +A stale server or too-old guest therefore fails closed. Boundaries that do +not carry a custom policy are unaffected: ordinary create, get, and list keep +working against any server version. Inspection does not report the policy — +it is create-time configuration, not box state. + +The cloud control plane does not carry the policy yet. `boxlite serve` and the +reference server are the server side of the contract above. The hosted API +does not advertise `linux_capabilities_enabled`, so a BoxLite client refuses +to send a policy to it — the gate is on the client, not the server. A client +that skips that negotiation and posts `advanced` anyway has the field +dropped, because the hosted API does not reject unknown properties. + +Named `get_or_create` on the local runtime refuses to adopt an existing box +whose capability policy differs from the requested one, so reuse cannot +silently widen or narrow privileges. + +An export carrying a capability policy is stamped archive v4; ordinary +exports stay v3. A pre-capability importer accepts only up to v3, so it +refuses the archive instead of dropping the policy and starting the box with +wider privileges than the archive asked for. + +Once a custom-policy box exists, do not roll a server back to a build that +predates these fields: such a build cannot preserve them while recreating the +box. Roll forward instead. + +## Project research + +The projects below were reviewed at their current primary-source interfaces. +Defaults differ by product, but direct container APIs consistently favor an +add/drop delta over exposing all five OCI sets. + +| Project | Interface and relevant behavior | +| --- | --- | +| Docker CLI | Repeatable string-list flags, forwarded without client-side semantic validation ([opts.go:150-156](https://github.com/docker/cli/blob/5b21d378b0db9eda911a169fd72cacb9f00da685/cli/command/container/opts.go#L150-L156), [opts.go:669-695](https://github.com/docker/cli/blob/5b21d378b0db9eda911a169fd72cacb9f00da685/cli/command/container/opts.go#L669-L695)). | +| Moby Engine | Flat `CapAdd` / `CapDrop` string arrays ([hostconfig.go:418-435](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/api/types/container/hostconfig.go#L418-L435)); 14-capability default and the precedence adopted above ([defaults.go:3-20](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/daemon/pkg/oci/caps/defaults.go#L3-L20), [utils.go:72-117](https://github.com/moby/moby/blob/2196ab2eec2aebaf92201056ea52475880397169/daemon/pkg/oci/caps/utils.go#L72-L117)). | +| Docker Compose | Flat `cap_add` / `cap_drop` sequences delegated to the engine ([Compose service specification](https://docs.docker.com/reference/compose-file/services/#cap_add)). | +| docker-py | Optional `cap_add` / `cap_drop` lists, not a closed capability enum ([containers.py:264-269](https://github.com/docker/docker-py/blob/main/docker/types/containers.py#L264-L269)). | +| Docker.DotNet | `IList` fields mirror Moby ([HostConfig.Generated.cs:80-87](https://github.com/dotnet/Docker.DotNet/blob/master/src/Docker.DotNet/Models/HostConfig.Generated.cs#L80-L87)). | +| Bollard | Rust `Option>` fields mirror Moby ([HostConfig.cap_add](https://docs.rs/bollard/latest/bollard/service/struct.HostConfig.html#structfield.cap_add)). | +| Podman/libpod | Flat string arrays ([specgen.go:394-401](https://github.com/containers/podman/blob/e36e1a41c69ea9f6096ed628a71920f315f34514/pkg/specgen/specgen.go#L394-L401)); its native endpoint rejects overlap, while its Docker-compatible endpoint accepts Moby's shape ([capabilities.go:125-196](https://github.com/containers/podman/blob/e36e1a41c69ea9f6096ed628a71920f315f34514/vendor/go.podman.io/common/pkg/capabilities/capabilities.go#L125-L196)). | +| podman-py | `list[str]` under the same keyword names ([containers_create.py:615-620](https://github.com/containers/podman-py/blob/main/podman/domain/containers_create.py#L615-L620)). | +| nerdctl | String slices and repeatable/comma-compatible flags; warns rather than freezing unknown names in the client ([container_run.go:218-222](https://github.com/containerd/nerdctl/blob/d79ad647152503c2740c90c1329cf985421e37b0/cmd/nerdctl/container/container_run.go#L218-L222), [run_security_linux.go:176-235](https://github.com/containerd/nerdctl/blob/d79ad647152503c2740c90c1329cf985421e37b0/pkg/cmd/container/run_security_linux.go#L176-L235)). | +| containerd | Lower-level ordered `SpecOpts`; add/drop mutate bounding, effective, and permitted, with inheritable/ambient handled separately ([spec_opts.go:1066-1143](https://github.com/containerd/containerd/blob/aad11006b869517fcd3009450b6f82da282e1a9b/pkg/oci/spec_opts.go#L1066-L1143)). | +| Kubernetes API | Nested `Capabilities { Add, Drop }`, but `Capability` is an open string newtype rather than an enum ([types.go:3040-3052](https://github.com/kubernetes/api/blob/master/core/v1/types.go#L3040-L3052)). | +| Kubernetes CRI | Repeated add/drop strings plus a separate ambient-add field; ordinary add and ambient add are intentionally distinct ([api.proto:1026-1041](https://github.com/kubernetes/cri-api/blob/791729b255f0c2d0019d3862ba6ef000c4a30c4d/pkg/apis/runtime/v1/api.proto#L1026-L1041)). | +| CRI-O | Guest/runtime-side validation, product-specific default, add/drop resolution, and deliberate clearing of ambient/inheritable sets ([capabilities_linux.go:11-42](https://github.com/cri-o/cri-o/blob/65f79695590b9f53e5f69b34382146cdac8ab5c0/internal/config/capabilities/capabilities_linux.go#L11-L42), [container.go:640-785](https://github.com/cri-o/cri-o/blob/65f79695590b9f53e5f69b34382146cdac8ab5c0/internal/factory/container/container.go#L640-L785)). | +| Nomad Docker driver | Flat task fields plus an operator allowlist; its default intentionally differs from Docker by dropping `NET_RAW` ([config.go:377-392](https://github.com/hashicorp/nomad/blob/37c73b2918bd5798e285623d436644f3e5d2cb1b/drivers/docker/config.go#L377-L392), [defaults.go:14-31](https://github.com/hashicorp/nomad/blob/37c73b2918bd5798e285623d436644f3e5d2cb1b/drivers/shared/capabilities/defaults.go#L14-L31)). | +| Buildah | Add/drop string arrays with documented drop-wins conflicts ([run.go:155-160](https://github.com/containers/buildah/blob/18bf8e35f1a08b95a9847e383d340bd9e96f5097/run.go#L155-L160), [buildah-run.1.md:26-48](https://github.com/containers/buildah/blob/18bf8e35f1a08b95a9847e383d340bd9e96f5097/docs/buildah-run.1.md#L26-L48)). | +| Apple container | Persisted `capAdd` / `capDrop` arrays default to empty for backward compatibility and document the same Moby precedence BoxLite adopts ([ContainerConfiguration.swift:20-60](https://github.com/apple/container/blob/d1d763530df3c6a326dbae7f0c0a59a335808045/Sources/ContainerResource/Container/ContainerConfiguration.swift#L20-L60), [how-to.md:470-510](https://github.com/apple/container/blob/d1d763530df3c6a326dbae7f0c0a59a335808045/docs/how-to.md#L470-L510)). | +| LXC | `lxc.cap.drop` and mutually exclusive `lxc.cap.keep` provide subtractive and replacement policies ([lxc.container.conf:1811-1850](https://github.com/lxc/lxc/blob/dc15af12c6a12d2946a5178001b3c377e2a9c694/doc/lxc.container.conf.sgml.in#L1811-L1850)). | +| Incus | Exposes LXC capability controls through restricted `raw.lxc`; privileged containers receive product-specific drops ([config_options.txt:2372-2378](https://github.com/lxc/incus/blob/29b2ba74073c5cd033865f7154e2c77ba5744824/doc/config_options.txt#L2372-L2378), [driver_lxc.go:787-798](https://github.com/lxc/incus/blob/29b2ba74073c5cd033865f7154e2c77ba5744824/internal/server/instance/drivers/driver_lxc.go#L787-L798)). | +| systemd-nspawn | Separate add, drop, and ambient settings; ambient is explicitly not implied by ordinary additions ([systemd.nspawn.xml:190-240](https://github.com/systemd/systemd/blob/ba3b1eff0ba51d400475f4596677b2d429cb1a47/man/systemd.nspawn.xml#L190-L240)). | +| OCI Runtime Spec | Exact `bounding`, `effective`, `inheritable`, `permitted`, and `ambient` arrays, with no delta/default policy ([config.md:286-299](https://github.com/opencontainers/runtime-spec/blob/6999a89a76a0329f440d5740497bedb9dd431297/config.md#L286-L299)). | +| runc | Applies the five exact OCI sets and resets ambient state explicitly ([capabilities.go:47-149](https://github.com/opencontainers/runc/blob/8d2f7df5cdcbd8d26b15457a9201f1c0ad426459/libcontainer/capabilities/capabilities.go#L47-L149)). Its inheritable-capability exec advisory is why BoxLite does not infer inheritable/ambient from `cap_add` ([GHSA-f3fp-gc8g-vw66](https://github.com/opencontainers/runc/security/advisories/GHSA-f3fp-gc8g-vw66)). | +| AWS ECS | Nested `KernelCapabilities` with add/drop arrays and Docker-derived semantics ([KernelCapabilities API](https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_KernelCapabilities.html)). | +| Azure Container Instances | Nested fluent `add` / `drop` string lists ([SecurityContextCapabilitiesDefinition](https://learn.microsoft.com/en-us/java/api/com.azure.resourcemanager.containerinstance.models.securitycontextcapabilitiesdefinition)). | +| Terraform Docker provider | Declarative nested block, but still only add/drop lists ([resource_docker_container.go:262-290](https://github.com/kreuzwerker/terraform-provider-docker/blob/master/internal/provider/resource_docker_container.go#L262-L290)). | + +Kata Containers and gVisor were also checked. Both consume OCI/containerd or +Docker/Kubernetes contracts rather than defining a competing high-level +capability API, which supports keeping BoxLite's public delta policy separate +from its OCI realization. + +## Alternatives rejected + +- **Public closed enum:** safer autocomplete today, but prevents a newer guest + from accepting a newer kernel capability until every SDK is released again. +- **Public five-set OCI object:** precise but too low-level for the common + container use case and easy to misuse. Ambient and inheritable deserve + separate review. +- **Flat top-level add/drop fields:** common in Docker-compatible engine APIs, + but BoxLite's top-level options also cover application lifecycle and resource + settings. Grouping the expert-only privilege policy under `advanced` keeps + creation extensible and matches Kubernetes, ECS, ACI, and Terraform's + structured security models. +- **Host semantic validation:** the host and guest may carry different OCI + libraries or kernels. Freezing the supported list in Rust, TypeScript, and + every SDK creates version skew; only lexical validation belongs upstream. diff --git a/docs/reference/README.md b/docs/reference/README.md index 7c563ec55..64c14de32 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -182,6 +182,25 @@ Host-side secret substitution rules for outbound HTTP(S) requests. - The placeholder is also exposed as `BOXLITE_SECRET_` inside the guest. - C SDK users configure secrets with `boxlite_options_add_secret()`. +#### `advanced.capabilities` + +Linux capability overrides for the container's init and all later exec +processes. Both lists default to empty, preserving BoxLite's Docker-compatible +14-capability baseline. + +- Names are case-insensitive and accept either `NET_ADMIN` or `CAP_NET_ADMIN`. +- `ALL` is supported in either list. +- Without `ALL`, drops are applied to the baseline before explicit additions, + so the same specifically named capability in both lists remains enabled. +- With `add=["ALL"]`, explicit drops win, matching Docker. +- Use `drop=["ALL"]` with explicit additions to construct a minimal set. +- Malformed names are rejected at the API boundary. A well-formed name that the + bundled guest runtime does not support is rejected during container initialization. +- A remote client and the host both check support before creating a box; a + custom policy is rejected rather than ignored when either side is too old. +- The resolved set is applied to OCI bounding, effective, and permitted sets. + Inheritable and ambient capabilities stay unset; they are not implied by `add`. + #### `cpus: int` Number of CPU cores allocated to the box. diff --git a/docs/reference/c/README.md b/docs/reference/c/README.md index fbba5dfd2..e21454058 100644 --- a/docs/reference/c/README.md +++ b/docs/reference/c/README.md @@ -117,6 +117,22 @@ int main() { return 1; } boxlite_options_set_network_enabled(opts); + CAdvancedBoxOptions* advanced = NULL; + if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + return 1; + } + const char* cap_add[] = {"NET_ADMIN"}; + const char* cap_drop[] = {"NET_RAW"}; + if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; + } + boxlite_options_set_advanced(opts, advanced); + boxlite_advanced_options_free(advanced); if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { fprintf(stderr, "Error %d: %s\n", error.code, error.message); @@ -562,6 +578,22 @@ if (boxlite_options_new("alpine:3.19", &opts, &error) != Ok) { boxlite_options_set_cpus(opts, 2); boxlite_options_set_memory(opts, 512); boxlite_options_set_network_enabled(opts); +CAdvancedBoxOptions* advanced = NULL; +if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + return 1; +} +const char* cap_add[] = {"NET_ADMIN"}; +const char* cap_drop[] = {"NET_RAW"}; +if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; +} +boxlite_options_set_advanced(opts, advanced); +boxlite_advanced_options_free(advanced); CBoxHandle* box = NULL; if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index edc343444..fdea3a9ed 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -246,7 +246,7 @@ The box's lifetime is that command's lifetime. When it exits, the box stops and takes the command's exit code; `boxlite ps` shows it stopped and `boxlite inspect -f '{{.State.ExitCode}}'` gives the code. -**Options:** Uses [`ProcessFlags`](#processflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags), plus: +**Options:** Uses [`ProcessFlags`](#processflags) + [`CapabilityFlags`](#capabilityflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags), plus: | Flag | Short | Description | |------|-------|-------------| @@ -266,6 +266,7 @@ boxlite run -it --rm alpine:latest /bin/sh boxlite run -d --name web -p 8080:80 nginx:alpine boxlite run -v $(pwd):/work -w /work alpine:latest ls -la boxlite run --cpus 4 --memory 4096 python:slim python -c "print(2+2)" +boxlite run --cap-add SYS_ADMIN --cap-drop NET_RAW alpine:latest sh boxlite run --rootfs /path/to/rootfs /bin/sh ``` @@ -321,7 +322,7 @@ implicitly, because starting it runs that command. Start it deliberately with | `--env KEY=VALUE` | `-e` | Set environment variables (repeatable) | | `--workdir PATH` | `-w` | Working directory inside the box | -Also uses [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags). +Also uses [`CapabilityFlags`](#capabilityflags) + [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + [`VolumeFlags`](#volumeflags) + [`ManagementFlags`](#managementflags). > Note: `create` accepts `--env` and `--workdir` directly rather than via `ProcessFlags` (no `-i`/`-t`/`-u` here, since no command is being executed). @@ -330,6 +331,7 @@ Also uses [`ResourceFlags`](#resourceflags) + [`PublishFlags`](#publishflags) + ```bash boxlite create --name mybox alpine:latest boxlite create -p 8080:80 -v /data:/app/data --name web nginx:alpine +boxlite create --cap-drop ALL --cap-add NET_BIND_SERVICE --name web nginx:alpine boxlite create --rootfs /path/to/rootfs --name local-rootfs ``` @@ -613,6 +615,21 @@ Used by `run` and `exec` (defined at `src/cli/src/cli.rs:208-281`). `--tty` implies `--interactive` when stdin is a TTY. `--tty` without a TTY-attached stdin is a hard error. +### `CapabilityFlags` + +Used by `run` and `create` to adjust the Linux capability set inherited by the +container's init and every later `exec` process. + +| Flag | Description | +|------|-------------| +| `--cap-add CAPABILITY` | Add a capability; repeatable | +| `--cap-drop CAPABILITY` | Drop a capability; repeatable | + +Names are case-insensitive and may include the `CAP_` prefix. `ALL` is +supported. With neither flag, BoxLite keeps its Docker-compatible 14-capability +baseline. `--cap-drop ALL --cap-add NET_BIND_SERVICE` creates a minimal set +containing only `NET_BIND_SERVICE`. + ### `ResourceFlags` Used by `run` and `create` (defined at `src/cli/src/cli.rs:287-310`). diff --git a/docs/reference/nodejs/README.md b/docs/reference/nodejs/README.md index 666577e46..1800179e2 100644 --- a/docs/reference/nodejs/README.md +++ b/docs/reference/nodejs/README.md @@ -106,9 +106,24 @@ Configuration options for creating a box. | `network` | `NetworkSpec` | `{ mode: "enabled" }` | Structured network configuration | | `ports` | `JsPortSpec[]` | `[]` | Port mappings | | `secrets` | `Secret[]` | `[]` | Outbound HTTP(S) secret substitution rules | +| `advanced` | `AdvancedBoxOptions` | `{}` | Expert-only options, including `capabilities.add` and `capabilities.drop` | | `autoRemove` | `boolean` | `false` | Auto cleanup when stopped | | `detach` | `boolean` | `false` | Survive parent process exit | +Capability policy is intentionally nested with the other expert-only options: + +```typescript +const options = { + image: "alpine:latest", + advanced: { + capabilities: { + add: ["NET_BIND_SERVICE"], + drop: ["NET_RAW"], + }, + }, +}; +``` + #### `NetworkSpec` ```typescript @@ -199,7 +214,6 @@ Metadata about a box. | `createdAt` | `string` | Creation timestamp (ISO 8601) | | `lastUpdated` | `string` | Last state change (ISO 8601) | | `pid` | `number \| undefined` | Process ID (if running) | - --- ## Command Execution @@ -314,6 +328,12 @@ interface SimpleBoxOptions { network?: NetworkSpec; ports?: PortSpec[]; // Port mappings secrets?: Secret[]; + advanced?: { + capabilities?: { + add?: string[]; // Add Linux capabilities + drop?: string[]; // Remove Linux capabilities + }; + }; } ``` diff --git a/docs/reference/python/README.md b/docs/reference/python/README.md index f8ee50411..4c314b718 100644 --- a/docs/reference/python/README.md +++ b/docs/reference/python/README.md @@ -132,9 +132,26 @@ Configuration options for creating a box. | `network` | `NetworkSpec \| None` | `None` | Structured network configuration. Omit for default enabled networking. | | `ports` | `List[Tuple[int, int, str]]` | `[]` | Port forwarding as (host_port, guest_port, protocol) | | `secrets` | `List[Secret]` | `[]` | Outbound HTTP(S) secret substitution rules | +| `advanced` | `AdvancedBoxOptions \| None` | `None` | Expert-only options, including `capabilities.add` and `capabilities.drop` | | `auto_remove` | `bool` | `True` | Auto cleanup when stopped | | `detach` | `bool` | `False` | Survive parent process exit | +Capability policy is intentionally nested with the other expert-only options: + +```python +from boxlite import AdvancedBoxOptions, BoxOptions, ContainerCapabilities + +options = BoxOptions( + image="alpine:latest", + advanced=AdvancedBoxOptions( + capabilities=ContainerCapabilities( + add=["NET_BIND_SERVICE"], + drop=["NET_RAW"], + ) + ), +) +``` + #### `NetworkSpec` ```python @@ -227,7 +244,6 @@ Metadata about a box. | `image` | `str` | OCI image used | | `cpus` | `int` | Allocated CPU cores | | `memory_mib` | `int` | Allocated memory in MiB | - --- ### `boxlite.BoxStateInfo` diff --git a/docs/reference/rust/README.md b/docs/reference/rust/README.md index 309bba868..ca300e3a6 100644 --- a/docs/reference/rust/README.md +++ b/docs/reference/rust/README.md @@ -536,7 +536,7 @@ pub struct BoxOptions { /// Run independently of parent process (default: false) pub detach: bool, - /// Advanced options for expert users (security, mount isolation). Defaults are secure. + /// Advanced options for expert users (capabilities, security, mount isolation). pub advanced: AdvancedBoxOptions, } ``` @@ -544,6 +544,7 @@ pub struct BoxOptions { #### Example ```rust +use boxlite::{AdvancedBoxOptions, ContainerCapabilities}; use boxlite::runtime::options::{BoxOptions, RootfsSpec, VolumeSpec, PortSpec}; let options = BoxOptions { @@ -567,6 +568,13 @@ let options = BoxOptions { ..Default::default() }, ], + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["SYS_ADMIN".to_string()], + drop: vec!["NET_RAW".to_string()], + }, + ..Default::default() + }, auto_remove: false, // Keep box after stop detach: true, // Run independently ..Default::default() @@ -580,6 +588,7 @@ the isolation protections supported by the host platform. ```rust pub struct AdvancedBoxOptions { + pub capabilities: ContainerCapabilities, pub security: SecurityOptions, pub isolate_mounts: bool, pub health_check: Option, @@ -588,6 +597,7 @@ pub struct AdvancedBoxOptions { | Field | Type | Default | Description | |-------|------|---------|-------------| +| `capabilities` | `ContainerCapabilities` | Empty add/drop lists | Linux capability delta policy for init and exec processes | | `security` | `SecurityOptions` | `SecurityOptions::default()` (fully enabled profile; jailer enabled) | Security isolation options (jailer, seccomp, namespaces) | | `isolate_mounts` | `bool` | `false` | Enable bind mount isolation (requires CAP_SYS_ADMIN on Linux) | | `health_check` | `Option` | `None` | Optional guest-agent health monitoring | diff --git a/make/test.mk b/make/test.mk index 910ef9824..8ae5b3104 100644 --- a/make/test.mk +++ b/make/test.mk @@ -1,4 +1,4 @@ -PHONY_TARGETS += test +PHONY_TARGETS += test test\:unit\:guest # Mirrors GitHub Actions strategy.fail-fast. Default false: aggregator # targets run every sub-suite even if an earlier one fails, then exit @@ -206,6 +206,22 @@ test\:unit\:rust: fi; \ exit $$rc +# Guest crate unit tests. Linux-only (the crate does not build elsewhere) and +# excluded from test:unit:rust because the zygote suite forks real processes. +# Runs the pure-logic modules, which is where capability resolution and OCI +# spec construction live — otherwise nothing exercises them. +test\:unit\:guest: + @if [ "$$(uname)" != "Linux" ]; then \ + echo "⏭️ Guest unit tests require Linux"; \ + exit 0; \ + fi; \ + echo "🧪 Running guest unit tests..."; \ + if command -v cargo-nextest >/dev/null 2>&1; then \ + cargo nextest run --no-tests=fail -p boxlite-guest -E 'test(~capabilit) + test(~spec::tests)'; \ + else \ + cargo test -p boxlite-guest --bins -- --test-threads=1 capabilit spec::tests; \ + fi + # Pre-warm Rust integration test image cache (internal helper, still callable). test\:warm-cache\:rust: $(if $(SETUP_DONE),,runtime\:debug) @echo "🔥 Warming Rust integration test image cache..." diff --git a/openapi/box.openapi.yaml b/openapi/box.openapi.yaml index ee6e8d5ec..34737e5b1 100644 --- a/openapi/box.openapi.yaml +++ b/openapi/box.openapi.yaml @@ -1249,6 +1249,10 @@ components: type: object description: Server capability limits and feature flags properties: + linux_capabilities_enabled: + type: boolean + description: Whether Docker-style Linux capability add/drop policy is supported + example: true max_cpus: type: integer description: Maximum allowed vCPUs per box @@ -1576,8 +1580,12 @@ components: CreateBoxRequest: type: object - description: Configuration for creating a new box (maps to BoxOptions) + description: Configuration for creating a new box. properties: + advanced: + $ref: "#/components/schemas/CreateBoxAdvancedOptions" + security: + $ref: "#/components/schemas/SecurityPreset" name: type: string description: Unique name within the workspace @@ -1670,8 +1678,38 @@ components: type: boolean default: true description: Whether the box automatically resumes when accessed after AutoPause - security: - $ref: "#/components/schemas/SecurityPreset" + + CreateBoxAdvancedOptions: + type: object + description: Expert-only container options. + additionalProperties: false + properties: + capabilities: + $ref: "#/components/schemas/CreateContainerCapabilities" + + CreateContainerCapabilities: + type: object + description: Linux capability additions and removals for container processes. + additionalProperties: false + properties: + add: + type: array + items: + type: string + default: [] + description: | + Linux capabilities to add to the default container capability set. + Names are case-insensitive, may include the `CAP_` prefix, and may be `ALL`. + example: [SYS_ADMIN] + drop: + type: array + items: + type: string + default: [] + description: | + Linux capabilities to remove from the container capability set. + Names are case-insensitive, may include the `CAP_` prefix, and may be `ALL`. + example: [NET_RAW] VolumeSpec: type: object diff --git a/openapi/reference-server/server.py b/openapi/reference-server/server.py index 90ff5ebbe..01b45bb85 100644 --- a/openapi/reference-server/server.py +++ b/openapi/reference-server/server.py @@ -58,7 +58,7 @@ ) from fastapi.responses import JSONResponse, StreamingResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from sse_starlette.sse import EventSourceResponse, ServerSentEvent import boxlite @@ -121,6 +121,39 @@ def validate_allow_net(self) -> "NetworkSpec": return self +class ContainerCapabilities(BaseModel): + model_config = ConfigDict(extra="forbid") + + add: list[str] = Field(default_factory=list) + drop: list[str] = Field(default_factory=list) + + @field_validator("add", "drop") + @classmethod + def validate_capabilities(cls, capabilities: list[str]) -> list[str]: + for capability in capabilities: + if not capability.isascii(): + raise ValueError("capability names must contain only ASCII characters") + normalized = capability.upper() + if normalized == "ALL": + continue + name = normalized.removeprefix("CAP_") + if ( + not name + or not name[0].isalpha() + or not all( + char.isalpha() or char.isdigit() or char == "_" for char in name + ) + ): + raise ValueError(f"malformed Linux capability: {capability}") + return capabilities + + +class CreateBoxAdvancedOptions(BaseModel): + model_config = ConfigDict(extra="forbid") + + capabilities: ContainerCapabilities = Field(default_factory=ContainerCapabilities) + + class CreateBoxRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -143,6 +176,7 @@ class CreateBoxRequest(BaseModel): auto_delete: Optional[int] = Field(default=None, ge=0) auto_resume: Optional[bool] = None detach: Optional[bool] = False + advanced: Optional[CreateBoxAdvancedOptions] = None security: Optional[str] = None @@ -216,6 +250,7 @@ def get_server_config() -> ServerConfig: raise RuntimeError("server configuration not initialized") return state.server_config + # ============================================================================ # Error Mapping # ============================================================================ @@ -370,6 +405,15 @@ def build_box_options(req: CreateBoxRequest) -> boxlite.BoxOptions: kwargs["cmd"] = req.cmd if req.user is not None: kwargs["user"] = req.user + if req.advanced is not None and ( + req.advanced.capabilities.add or req.advanced.capabilities.drop + ): + kwargs["advanced"] = boxlite.AdvancedBoxOptions( + capabilities=boxlite.ContainerCapabilities( + add=req.advanced.capabilities.add, + drop=req.advanced.capabilities.drop, + ) + ) if req.secrets: kwargs["secrets"] = [ boxlite.Secret( @@ -543,6 +587,7 @@ async def get_config(): }, "overrides": {}, "capabilities": { + "linux_capabilities_enabled": True, "max_cpus": 32, "max_memory_mib": 16384, "max_disk_size_gb": 100, diff --git a/openapi/reference-server/tests/test_handle_cache.py b/openapi/reference-server/tests/test_handle_cache.py index 7356d5699..6c16a72a3 100644 --- a/openapi/reference-server/tests/test_handle_cache.py +++ b/openapi/reference-server/tests/test_handle_cache.py @@ -43,6 +43,8 @@ def maximum(): module.Boxlite = _Noop module.Options = _Noop module.BoxOptions = _Noop + module.AdvancedBoxOptions = _Noop + module.ContainerCapabilities = _Noop module.CloneOptions = _Noop module.ExportOptions = _Noop module.SnapshotOptions = _Noop @@ -155,6 +157,57 @@ async def test_create_box_caches_handle(self) -> None: self.assertEqual(payload["box_id"], "box-create") self.assertIn("box-create", SERVER.state.active_boxes_by_id) + def test_build_box_options_forwards_capability_policy(self) -> None: + request = SERVER.CreateBoxRequest( + advanced=SERVER.CreateBoxAdvancedOptions( + capabilities=SERVER.ContainerCapabilities( + add=["SYS_ADMIN"], + drop=["CAP_NET_RAW"], + ) + ), + ) + + capabilities = object() + advanced = object() + with ( + patch.object( + SERVER.boxlite, + "ContainerCapabilities", + return_value=capabilities, + ) as capabilities_constructor, + patch.object( + SERVER.boxlite, + "AdvancedBoxOptions", + return_value=advanced, + ) as advanced_constructor, + patch.object( + SERVER.boxlite, + "BoxOptions", + return_value=object(), + ) as constructor, + ): + SERVER.build_box_options(request) + + capabilities_constructor.assert_called_once_with( + add=["SYS_ADMIN"], + drop=["CAP_NET_RAW"], + ) + advanced_constructor.assert_called_once_with(capabilities=capabilities) + constructor.assert_called_once_with( + image="alpine:latest", + advanced=advanced, + detach=False, + ) + + def test_create_box_rejects_malformed_capability_policy(self) -> None: + for capability in ("NET-ADMIN", "123", "ß"): + with self.assertRaises(ValueError): + SERVER.CreateBoxRequest( + advanced=SERVER.CreateBoxAdvancedOptions( + capabilities=SERVER.ContainerCapabilities(add=[capability]) + ) + ) + async def test_clone_box_caches_cloned_handle(self) -> None: source = _make_box_handle("box-source") cloned = _make_box_handle("box-cloned") diff --git a/sdks/c/README.md b/sdks/c/README.md index 2c8c77eb7..a55fabcf9 100644 --- a/sdks/c/README.md +++ b/sdks/c/README.md @@ -218,6 +218,24 @@ int main() { } boxlite_options_set_network_enabled(opts); + CAdvancedBoxOptions* advanced = NULL; + if (boxlite_advanced_options_new(&advanced, &error) != Ok) { + boxlite_options_free(opts); + boxlite_runtime_free(runtime); + return 1; + } + const char* cap_add[] = {"NET_ADMIN"}; + const char* cap_drop[] = {"NET_RAW"}; + if (boxlite_advanced_options_set_capabilities_add(advanced, cap_add, 1) != Ok || + boxlite_advanced_options_set_capabilities_drop(advanced, cap_drop, 1) != Ok) { + fprintf(stderr, "Invalid Linux capability list\n"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + return 1; + } + boxlite_options_set_advanced(opts, advanced); + boxlite_advanced_options_free(advanced); + if (boxlite_create_box(runtime, opts, &box, &error) != Ok) { fprintf(stderr, "Error %d: %s\n", error.code, error.message); boxlite_error_free(&error); diff --git a/sdks/c/include/boxlite.h b/sdks/c/include/boxlite.h index afc4d36c8..c9e7def80 100644 --- a/sdks/c/include/boxlite.h +++ b/sdks/c/include/boxlite.h @@ -410,6 +410,22 @@ void boxlite_advanced_options_free(CAdvancedBoxOptions *opts); // genuinely can't sandbox). Null `opts` is a no-op. void boxlite_advanced_options_set_security_enabled(CAdvancedBoxOptions *opts, int enabled); +// Replace the capabilities added to BoxLite's Docker-compatible baseline. +// +// A zero count clears the list. Negative counts, null handles, null arrays +// with a positive count, null elements, and invalid UTF-8 fail closed. +enum BoxliteErrorCode boxlite_advanced_options_set_capabilities_add(CAdvancedBoxOptions *opts, + const char *const *capabilities, + int count); + +// Replace the capabilities removed from the container capability set. +// +// A zero count clears the list. Negative counts, null handles, null arrays +// with a positive count, null elements, and invalid UTF-8 fail closed. +enum BoxliteErrorCode boxlite_advanced_options_set_capabilities_drop(CAdvancedBoxOptions *opts, + const char *const *capabilities, + int count); + enum BoxliteErrorCode boxlite_create_box(CBoxliteRuntime *runtime, CBoxliteOptions *opts, CBoxCreateBoxCb cb, @@ -599,57 +615,49 @@ enum BoxliteErrorCode boxlite_runtime_metrics(CBoxliteRuntime *runtime, void *user_data, CBoxliteError *out_error); -/** - * Borrow the box's network capability into a new owned handle. - * - * On success, `*out_network` must be released with `boxlite_network_free`. - * Returns `InvalidArgument` for null input/output pointers and writes details - * to `out_error` when provided. - */ +// Borrow the box's network capability into a new owned handle. +// +// On success, `*out_network` must be released with `boxlite_network_free`. +// Returns `InvalidArgument` for null input/output pointers and writes details +// to `out_error` when provided. enum BoxliteErrorCode boxlite_box_network(CBoxHandle *handle, CBoxNetworkHandle **out_network, CBoxliteError *out_error); -/** Release a network handle. Accepts NULL and does not affect the box handle. */ +// Release a network handle. Accepts NULL and does not affect the box handle. void boxlite_network_free(CBoxNetworkHandle *network); -/** - * Prepare a one-shot tunnel to `port` in the box. - * - * On success, `*out_tunnel` owns a handle that must be released with - * `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output - * pointer or port zero, with details written to `out_error` when provided. - */ +// Prepare a one-shot tunnel to `port` in the box. +// +// On success, `*out_tunnel` owns a handle that must be released with +// `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output +// pointer or port zero, with details written to `out_error` when provided. enum BoxliteErrorCode boxlite_network_tunnel(CBoxNetworkHandle *network, uint16_t port, CBoxTunnelHandle **out_tunnel, CBoxliteError *out_error); -/** Release a tunnel handle and any unconsumed connection. Accepts NULL. */ +// Release a tunnel handle and any unconsumed connection. Accepts NULL. void boxlite_tunnel_free(CBoxTunnelHandle *tunnel); -/** - * Inspect a prepared tunnel without transferring ownership. - * - * `out_type` selects the valid output: URI returns an allocated `*out_uri` - * that the caller must release with `boxlite_free_string`; FileDescriptor - * returns a borrowed `*out_fd` valid only while the tunnel remains alive. - * Unused outputs are initialized to NULL and -1. Errors are returned as a - * `BoxliteErrorCode` and described through `out_error` when provided. - */ +// Inspect a prepared tunnel without transferring ownership. +// +// `out_type` selects the valid output: URI returns an allocated `*out_uri` +// that the caller must release with `boxlite_free_string`; FileDescriptor +// returns a borrowed `*out_fd` valid only while the tunnel remains alive. +// Unused outputs are initialized to NULL and -1. Errors are returned as a +// `BoxliteErrorCode` and described through `out_error` when provided. enum BoxliteErrorCode boxlite_tunnel_endpoint(CBoxTunnelHandle *tunnel, enum BoxliteEndpointType *out_type, char **out_uri, int32_t *out_fd, CBoxliteError *out_error); -/** - * Consume a tunnel's single connection and return its owned file descriptor. - * - * On success, the caller owns `*out_fd` and must close it. A second call - * returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` - * receives details when provided. - */ +// Consume a tunnel's single connection and return its owned file descriptor. +// +// On success, the caller owns `*out_fd` and must close it. A second call +// returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` +// receives details when provided. enum BoxliteErrorCode boxlite_tunnel_connect(CBoxTunnelHandle *tunnel, int32_t *out_fd, CBoxliteError *out_error); @@ -704,7 +712,7 @@ void boxlite_options_add_secret(CBoxliteOptions *opts, const char *const *hosts, int hosts_count); -// Deprecated: use boxlite_options_set_auto_delete_interval. +// Deprecated: use `boxlite_options_set_auto_delete_interval`. void boxlite_options_set_auto_remove(CBoxliteOptions *opts, int val); void boxlite_options_set_auto_pause_interval(CBoxliteOptions *opts, uint32_t seconds); @@ -715,7 +723,7 @@ void boxlite_options_set_auto_resume_enabled(CBoxliteOptions *opts, int val); void boxlite_options_set_detach(CBoxliteOptions *opts, int val); -// Apply a `CAdvancedBoxOptions` (security, mount isolation, health check) to a +// Apply a `CAdvancedBoxOptions` (capabilities, security, mount isolation, health check) to a // `CBoxliteOptions`. Clones the advanced configuration into the box options — // the caller retains ownership of `advanced_opts` and is responsible for // freeing it via `boxlite_advanced_options_free`. diff --git a/sdks/c/src/advanced_options.rs b/sdks/c/src/advanced_options.rs index 29fb9a153..3e93686da 100644 --- a/sdks/c/src/advanced_options.rs +++ b/sdks/c/src/advanced_options.rs @@ -1,17 +1,18 @@ //! C ABI for `boxlite::runtime::advanced_options::AdvancedBoxOptions`. //! -//! Mirrors the core model: advanced knobs (security, mount isolation, health -//! check) live under `BoxOptions.advanced`, never directly on the box. Build a +//! Mirrors the core model: advanced knobs (capabilities, security, mount +//! isolation, health check) live under `BoxOptions.advanced`, never directly on the box. Build a //! `CAdvancedBoxOptions` handle via `boxlite_advanced_options_new`, toggle the //! sandbox with `boxlite_advanced_options_set_security_enabled`, then apply it //! to a `CBoxliteOptions` via `boxlite_options_set_advanced`. -use std::os::raw::c_int; +use std::os::raw::{c_char, c_int}; use boxlite::runtime::advanced_options::{AdvancedBoxOptions, SecurityOptions}; use crate::CAdvancedBoxOptions; use crate::error::{BoxliteErrorCode, FFIError, null_pointer_error, write_error}; +use crate::util::c_str_to_string; /// Opaque handle wrapping an `AdvancedBoxOptions`. Allocated via /// `boxlite_advanced_options_new`, freed via `boxlite_advanced_options_free`. @@ -77,3 +78,90 @@ pub unsafe extern "C" fn boxlite_advanced_options_set_security_enabled( }; } } + +/// Replace the capabilities added to BoxLite's Docker-compatible baseline. +/// +/// A zero count clears the list. Negative counts, null handles, null arrays +/// with a positive count, null elements, and invalid UTF-8 fail closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_advanced_options_set_capabilities_add( + opts: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, +) -> BoxliteErrorCode { + set_capability_list(opts, capabilities, count, |options, values| { + options.capabilities.add = values; + }) +} + +/// Replace the capabilities removed from the container capability set. +/// +/// A zero count clears the list. Negative counts, null handles, null arrays +/// with a positive count, null elements, and invalid UTF-8 fail closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn boxlite_advanced_options_set_capabilities_drop( + opts: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, +) -> BoxliteErrorCode { + set_capability_list(opts, capabilities, count, |options, values| { + options.capabilities.drop = values; + }) +} + +const INVALID_CAPABILITY_INPUT: &str = ""; + +fn set_capability_list( + handle: *mut CAdvancedBoxOptions, + capabilities: *const *const c_char, + count: c_int, + assign: impl FnOnce(&mut AdvancedBoxOptions, Vec), +) -> BoxliteErrorCode { + let Some(handle) = (unsafe { handle.as_mut() }) else { + return BoxliteErrorCode::InvalidArgument; + }; + + match parse_capability_array(capabilities, count) { + Ok(values) => { + assign(&mut handle.options, values); + BoxliteErrorCode::Ok + } + Err(()) => { + // Keep the handle invalid if a caller ignores the return code. The + // subsequent BoxOptions::sanitize call then rejects the policy + // instead of silently falling back to the baseline. + assign( + &mut handle.options, + vec![INVALID_CAPABILITY_INPUT.to_string()], + ); + BoxliteErrorCode::InvalidArgument + } + } +} + +fn parse_capability_array( + capabilities: *const *const c_char, + count: c_int, +) -> Result, ()> { + if count < 0 { + return Err(()); + } + if count == 0 { + return Ok(Vec::new()); + } + if capabilities.is_null() { + return Err(()); + } + + let mut values = Vec::with_capacity(count as usize); + unsafe { + for index in 0..count { + let capability = *capabilities.add(index as usize); + if capability.is_null() { + return Err(()); + } + values.push(c_str_to_string(capability).map_err(|_| ())?); + } + } + Ok(values) +} diff --git a/sdks/c/src/info.rs b/sdks/c/src/info.rs index d40bcde61..08d9c88f0 100644 --- a/sdks/c/src/info.rs +++ b/sdks/c/src/info.rs @@ -112,11 +112,10 @@ pub unsafe fn free_box_info_list(list: *mut CBoxInfoList) { free_box_info(list_ref.items.add(idx as usize)); } if !list_ref.items.is_null() { - drop(Vec::from_raw_parts( + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( list_ref.items, list_ref.count as usize, - list_ref.count as usize, - )); + ))); } drop(Box::from_raw(list)); } @@ -267,10 +266,19 @@ unsafe fn box_list( runtime_ref.tokio_rt.spawn(async move { let result = runtime_clone.list_info().await.map(|boxes| { - let mut items: Vec = boxes.iter().map(CBoxInfo::from_box_info).collect(); + let mut items = boxes + .iter() + .map(CBoxInfo::from_box_info) + .collect::>() + .into_boxed_slice(); let count = items.len() as c_int; - let ptr = items.as_mut_ptr(); - std::mem::forget(items); + let ptr = if items.is_empty() { + ptr::null_mut() + } else { + let ptr = items.as_mut_ptr(); + Box::leak(items); + ptr + }; crate::event_queue::OwnedFfiPtr::new_with( Box::new(CBoxInfoList { items: ptr, count }), free_box_info_list, diff --git a/sdks/c/src/network.rs b/sdks/c/src/network.rs index 94902bf91..9f4b71740 100644 --- a/sdks/c/src/network.rs +++ b/sdks/c/src/network.rs @@ -50,6 +50,11 @@ pub enum BoxliteEndpointType { BoxliteEndpointTypeFileDescriptor = 1, } +/// Borrow the box's network capability into a new owned handle. +/// +/// On success, `*out_network` must be released with `boxlite_network_free`. +/// Returns `InvalidArgument` for null input/output pointers and writes details +/// to `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_box_network( handle: *mut CBoxHandle, @@ -76,6 +81,7 @@ pub unsafe extern "C" fn boxlite_box_network( } } +/// Release a network handle. Accepts NULL and does not affect the box handle. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_network_free(network: *mut CBoxNetworkHandle) { if !network.is_null() { @@ -83,6 +89,11 @@ pub unsafe extern "C" fn boxlite_network_free(network: *mut CBoxNetworkHandle) { } } +/// Prepare a one-shot tunnel to `port` in the box. +/// +/// On success, `*out_tunnel` owns a handle that must be released with +/// `boxlite_tunnel_free`. Returns `InvalidArgument` for a null network/output +/// pointer or port zero, with details written to `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_network_tunnel( network: *mut CBoxNetworkHandle, @@ -141,6 +152,7 @@ pub unsafe extern "C" fn boxlite_network_tunnel( } } +/// Release a tunnel handle and any unconsumed connection. Accepts NULL. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_free(tunnel: *mut CBoxTunnelHandle) { if !tunnel.is_null() { @@ -148,6 +160,13 @@ pub unsafe extern "C" fn boxlite_tunnel_free(tunnel: *mut CBoxTunnelHandle) { } } +/// Inspect a prepared tunnel without transferring ownership. +/// +/// `out_type` selects the valid output: URI returns an allocated `*out_uri` +/// that the caller must release with `boxlite_free_string`; FileDescriptor +/// returns a borrowed `*out_fd` valid only while the tunnel remains alive. +/// Unused outputs are initialized to NULL and -1. Errors are returned as a +/// `BoxliteErrorCode` and described through `out_error` when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_endpoint( tunnel: *mut CBoxTunnelHandle, @@ -216,6 +235,11 @@ pub unsafe extern "C" fn boxlite_tunnel_endpoint( } } +/// Consume a tunnel's single connection and return its owned file descriptor. +/// +/// On success, the caller owns `*out_fd` and must close it. A second call +/// returns `InvalidState`. On failure `*out_fd` remains -1 and `out_error` +/// receives details when provided. #[unsafe(no_mangle)] pub unsafe extern "C" fn boxlite_tunnel_connect( tunnel: *mut CBoxTunnelHandle, diff --git a/sdks/c/src/options.rs b/sdks/c/src/options.rs index fe133468f..d01130f47 100644 --- a/sdks/c/src/options.rs +++ b/sdks/c/src/options.rs @@ -190,7 +190,7 @@ pub unsafe extern "C" fn boxlite_options_set_detach(opts: *mut CBoxliteOptions, options_set_detach(opts, val) } -/// Apply a `CAdvancedBoxOptions` (security, mount isolation, health check) to a +/// Apply a `CAdvancedBoxOptions` (capabilities, security, mount isolation, health check) to a /// `CBoxliteOptions`. Clones the advanced configuration into the box options — /// the caller retains ownership of `advanced_opts` and is responsible for /// freeing it via `boxlite_advanced_options_free`. diff --git a/sdks/c/src/tests.rs b/sdks/c/src/tests.rs index 2405b7951..63946def0 100644 --- a/sdks/c/src/tests.rs +++ b/sdks/c/src/tests.rs @@ -423,6 +423,160 @@ fn auto_remove_and_auto_delete_use_last_call_wins() { } } +#[test] +fn capability_lists_default_empty_and_preserve_custom_values() { + let image = CString::new("alpine:latest").expect("image cstring"); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + unsafe { + assert!((*advanced).options.capabilities.add.is_empty()); + assert!((*advanced).options.capabilities.drop.is_empty()); + } + + let cap_add = [ + CString::new("NET_ADMIN").unwrap(), + CString::new("SYS_PTRACE").unwrap(), + ]; + let cap_add_ptrs: Vec<*const std::os::raw::c_char> = + cap_add.iter().map(|cap| cap.as_ptr()).collect(); + let cap_drop = [ + CString::new("MKNOD").unwrap(), + CString::new("NET_RAW").unwrap(), + ]; + let cap_drop_ptrs: Vec<*const std::os::raw::c_char> = + cap_drop.iter().map(|cap| cap.as_ptr()).collect(); + + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add( + advanced, + cap_add_ptrs.as_ptr(), + cap_add_ptrs.len() as c_int, + ), + BoxliteErrorCode::Ok + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_drop( + advanced, + cap_drop_ptrs.as_ptr(), + cap_drop_ptrs.len() as c_int + ), + BoxliteErrorCode::Ok + ); + boxlite_options_set_advanced(opts, advanced); + + assert_eq!( + (*opts).options.advanced.capabilities.add, + ["NET_ADMIN", "SYS_PTRACE"] + ); + assert_eq!( + (*opts).options.advanced.capabilities.drop, + ["MKNOD", "NET_RAW"] + ); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn null_capability_element_cannot_weaken_policy() { + let image = CString::new("alpine:latest").unwrap(); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + let malformed = [ptr::null()]; + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_drop(advanced, malformed.as_ptr(), 1), + BoxliteErrorCode::InvalidArgument + ); + boxlite_options_set_advanced(opts, advanced); + (*opts) + .options + .sanitize() + .expect_err("a null cap_drop element must fail closed"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn invalid_utf8_capability_cannot_weaken_policy() { + let image = CString::new("alpine:latest").unwrap(); + let mut opts: *mut CBoxliteOptions = ptr::null_mut(); + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_options_new(image.as_ptr(), &mut opts, &mut error) }, + BoxliteErrorCode::Ok + ); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + let invalid_utf8 = [0xff_u8, 0]; + let malformed = [invalid_utf8.as_ptr().cast::()]; + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add(advanced, malformed.as_ptr(), 1), + BoxliteErrorCode::InvalidArgument + ); + boxlite_options_set_advanced(opts, advanced); + (*opts) + .options + .sanitize() + .expect_err("invalid UTF-8 in cap_add must fail closed"); + boxlite_advanced_options_free(advanced); + boxlite_options_free(opts); + } +} + +#[test] +fn invalid_capability_count_and_null_array_fail_closed() { + let mut advanced: *mut CAdvancedBoxOptions = ptr::null_mut(); + let mut error = FFIError::default(); + assert_eq!( + unsafe { boxlite_advanced_options_new(&mut advanced, &mut error) }, + BoxliteErrorCode::Ok + ); + + unsafe { + assert_eq!( + boxlite_advanced_options_set_capabilities_add(advanced, ptr::null(), -1), + BoxliteErrorCode::InvalidArgument + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_drop(advanced, ptr::null(), 1), + BoxliteErrorCode::InvalidArgument + ); + assert_eq!( + boxlite_advanced_options_set_capabilities_add(ptr::null_mut(), ptr::null(), 0), + BoxliteErrorCode::InvalidArgument + ); + boxlite_advanced_options_free(advanced); + } +} + // Security is toggled through the advanced layer: // `boxlite_advanced_options_set_security_enabled` selects the enabled/disabled // profile on a `CAdvancedBoxOptions`, then `boxlite_options_set_advanced` diff --git a/sdks/go/README.md b/sdks/go/README.md index 9dc1459cc..81d0fa176 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -96,6 +96,18 @@ for _, image := range cached { - `WithNetwork(boxlite.NetworkSpec{Mode: boxlite.NetworkModeEnabled, AllowNet: []string{"api.openai.com"}})` restricts outbound traffic while keeping networking enabled. - `WithNetwork(boxlite.NetworkSpec{Mode: boxlite.NetworkModeDisabled})` disables the guest network interface entirely. - `WithSecret(boxlite.Secret{...})` configures host-side HTTP(S) secret substitution; `Placeholder` defaults to ``. +- Container capabilities live under advanced options: + + ```go + advanced, err := boxlite.NewAdvancedBoxOptions() + if err != nil { log.Fatal(err) } + defer advanced.Close() + if err := advanced.SetCapabilities(boxlite.ContainerCapabilities{ + Add: []string{"NET_ADMIN"}, + Drop: []string{"NET_RAW"}, + }); err != nil { log.Fatal(err) } + box, err := runtime.Create(ctx, "alpine:latest", boxlite.WithAdvancedOptions(advanced)) + ``` ## Development diff --git a/sdks/go/advanced_options.go b/sdks/go/advanced_options.go index 69792c63a..99847602b 100644 --- a/sdks/go/advanced_options.go +++ b/sdks/go/advanced_options.go @@ -1,10 +1,8 @@ -// AdvancedBoxOptions groups the box-level advanced knobs (currently the -// security toggle) under one handle, mirroring core `BoxOptions.advanced`. +// AdvancedBoxOptions groups box-level capability and security knobs under one +// handle, mirroring core `BoxOptions.advanced`. // -// Build it via `NewAdvancedBoxOptions`, toggle the sandbox with -// `SetSecurityEnabled`, and pass it to `runtime.Create(..., WithAdvancedOptions(adv))`. -// Security is reached through this layer (never attached to the box directly), -// matching the core `BoxOptions.advanced.security` model. +// Build it via `NewAdvancedBoxOptions`, configure capabilities or security, +// and pass it to `runtime.Create(..., WithAdvancedOptions(adv))`. // // adv, _ := boxlite.NewAdvancedBoxOptions() // defer adv.Close() @@ -18,13 +16,24 @@ package boxlite */ import "C" -import "runtime" +import ( + "fmt" + "runtime" +) + +// ContainerCapabilities is the requested Linux capability policy. +// Capability names may be written with or without the CAP_ prefix. +type ContainerCapabilities struct { + Add []string + Drop []string +} // AdvancedBoxOptions is the Go-side handle for a `CAdvancedBoxOptions`. // Construct via `NewAdvancedBoxOptions`; release via `Close` once it has // been attached to a box (or you no longer need it). type AdvancedBoxOptions struct { - handle *C.CAdvancedBoxOptions + handle *C.CAdvancedBoxOptions + capabilities ContainerCapabilities } // NewAdvancedBoxOptions allocates an advanced-options handle initialized to @@ -52,6 +61,41 @@ func (a *AdvancedBoxOptions) SetSecurityEnabled(enabled bool) { C.boxlite_advanced_options_set_security_enabled(a.handle, boolToCInt(enabled)) } +// SetCapabilities replaces advanced.capabilities for subsequently created +// boxes. The input slices are copied; callers may safely reuse or mutate them +// after this method returns. +func (a *AdvancedBoxOptions) SetCapabilities(capabilities ContainerCapabilities) error { + if a == nil || a.handle == nil { + return fmt.Errorf("boxlite: advanced options handle is closed") + } + if err := validateCapabilities("advanced.capabilities.add", capabilities.Add); err != nil { + return err + } + if err := validateCapabilities("advanced.capabilities.drop", capabilities.Drop); err != nil { + return err + } + + add, addCount := toCStringArray(capabilities.Add) + addCode := C.boxlite_advanced_options_set_capabilities_add(a.handle, add, C.int(addCount)) + freeCStringArray(add, addCount) + if addCode != C.Ok { + return fmt.Errorf("boxlite: invalid advanced.capabilities.add") + } + + drop, dropCount := toCStringArray(capabilities.Drop) + dropCode := C.boxlite_advanced_options_set_capabilities_drop(a.handle, drop, C.int(dropCount)) + freeCStringArray(drop, dropCount) + if dropCode != C.Ok { + return fmt.Errorf("boxlite: invalid advanced.capabilities.drop") + } + + a.capabilities = ContainerCapabilities{ + Add: append([]string(nil), capabilities.Add...), + Drop: append([]string(nil), capabilities.Drop...), + } + return nil +} + // Close releases the underlying CAdvancedBoxOptions. Idempotent. func (a *AdvancedBoxOptions) Close() { if a == nil || a.handle == nil { @@ -59,5 +103,6 @@ func (a *AdvancedBoxOptions) Close() { } C.boxlite_advanced_options_free(a.handle) a.handle = nil + a.capabilities = ContainerCapabilities{} runtime.SetFinalizer(a, nil) } diff --git a/sdks/go/boxlite_test.go b/sdks/go/boxlite_test.go index 77001fe9d..486f6ce1d 100644 --- a/sdks/go/boxlite_test.go +++ b/sdks/go/boxlite_test.go @@ -2,6 +2,7 @@ package boxlite import ( "errors" + "reflect" "testing" "unsafe" ) @@ -270,6 +271,73 @@ func TestBoxOptions(t *testing.T) { } } +func TestAdvancedOptionsSetCapabilitiesDeepCopies(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + policy := ContainerCapabilities{ + Add: []string{"NET_ADMIN", "SYS_PTRACE"}, + Drop: []string{"MKNOD", "NET_RAW"}, + } + if err := advanced.SetCapabilities(policy); err != nil { + t.Fatalf("SetCapabilities: %v", err) + } + policy.Add[0] = "CHOWN" + policy.Drop[0] = "SETUID" + + wantAdd := []string{"NET_ADMIN", "SYS_PTRACE"} + wantDrop := []string{"MKNOD", "NET_RAW"} + if !reflect.DeepEqual(advanced.capabilities.Add, wantAdd) { + t.Fatalf("advanced.capabilities.add: got %v, want %v", advanced.capabilities.Add, wantAdd) + } + if !reflect.DeepEqual(advanced.capabilities.Drop, wantDrop) { + t.Fatalf("advanced.capabilities.drop: got %v, want %v", advanced.capabilities.Drop, wantDrop) + } + + cfg := &boxConfig{} + WithAdvancedOptions(advanced)(cfg) + if err := buildAndFreeCOptions("alpine:latest", cfg); err != nil { + t.Fatalf("nested capability options must apply cleanly: %v", err) + } +} + +func TestSetCapabilitiesRejectsEmbeddedNUL(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + err = advanced.SetCapabilities(ContainerCapabilities{Add: []string{"SYS_ADMIN\x00garbage"}}) + if err == nil { + t.Fatal("embedded NUL in a capability must not be truncated into a valid capability") + } +} + +func TestSetCapabilitiesReturnsInvalidArgumentForMalformedNames(t *testing.T) { + advanced, err := NewAdvancedBoxOptions() + if err != nil { + t.Fatalf("NewAdvancedBoxOptions: %v", err) + } + defer advanced.Close() + + err = advanced.SetCapabilities(ContainerCapabilities{Add: []string{"NET-ADMIN"}}) + if err == nil { + t.Fatal("malformed capability must be rejected") + } + + var boxliteErr *Error + if !errors.As(err, &boxliteErr) { + t.Fatalf("expected *Error, got %T: %v", err, err) + } + if boxliteErr.Code != ErrInvalidArgument { + t.Fatalf("Code: got %d, want %d", boxliteErr.Code, ErrInvalidArgument) + } +} + func TestWithPortExplicitSpec(t *testing.T) { cfg := &boxConfig{} WithPort(PortSpec{ diff --git a/sdks/go/options.go b/sdks/go/options.go index dc2f645af..eb112c199 100644 --- a/sdks/go/options.go +++ b/sdks/go/options.go @@ -343,14 +343,13 @@ func buildAndFreeCOptions(image string, cfg *boxConfig) error { return nil } -// WithAdvancedOptions attaches advanced box options (currently the security -// toggle) to the box. Security is reached through this layer, mirroring the -// core `BoxOptions.advanced.security` model. +// WithAdvancedOptions attaches advanced capability and security options to the +// box, mirroring the core `BoxOptions.advanced` model. // -// Build the handle via NewAdvancedBoxOptions and toggle the sandbox with -// SetSecurityEnabled. The caller retains ownership and must call `adv.Close()` -// after the box has been created (or sooner, if discarded). If never called, -// the box uses the defaults (the fully-isolated security profile). +// Build the handle via NewAdvancedBoxOptions and configure it with +// SetCapabilities and/or SetSecurityEnabled. The caller retains ownership and +// must call `adv.Close()` after the box has been created (or sooner, if +// discarded). If never called, the box uses the defaults. // // adv, _ := boxlite.NewAdvancedBoxOptions() // defer adv.Close() @@ -503,7 +502,7 @@ func buildCOptions(image string, cfg *boxConfig) (*C.CBoxliteOptions, error) { C.boxlite_options_set_detach(cOpts, boolToCInt(*cfg.detach)) } if cfg.advanced != nil && cfg.advanced.handle != nil { - // Clone the caller-owned advanced options (security, …) onto the box. + // Clone the caller-owned advanced options onto the box. // The Go-side handle stays caller-owned; the box has its own copy after // set_advanced returns. C.boxlite_options_set_advanced(cOpts, cfg.advanced.handle) @@ -518,10 +517,36 @@ func buildCOptions(image string, cfg *boxConfig) (*C.CBoxliteOptions, error) { C.boxlite_options_set_cmd(cOpts, cArgs, C.int(argc)) freeCStringArray(cArgs, argc) } - return cOpts, nil } +func validateCapabilities(option string, capabilities []string) error { + for _, capability := range capabilities { + normalized := strings.ToUpper(capability) + if normalized == "ALL" { + continue + } + name := strings.TrimPrefix(normalized, "CAP_") + if name == "" { + return &Error{ + Code: ErrInvalidArgument, + Message: fmt.Sprintf("empty Linux capability in %s", option), + } + } + for index, character := range []byte(name) { + isLetter := character >= 'A' && character <= 'Z' + isTail := isLetter || character >= '0' && character <= '9' || character == '_' + if index == 0 && !isLetter || index > 0 && !isTail { + return &Error{ + Code: ErrInvalidArgument, + Message: fmt.Sprintf("malformed Linux capability in %s: %q", option, capability), + } + } + } + } + return nil +} + func boolToCInt(v bool) C.int { if v { return 1 diff --git a/sdks/go/runtime.go b/sdks/go/runtime.go index c32f9cae0..fd7b04f2b 100644 --- a/sdks/go/runtime.go +++ b/sdks/go/runtime.go @@ -191,6 +191,9 @@ func (r *Runtime) Create(ctx context.Context, image string, opts ...BoxOption) ( // The second return value, created, is true when a new box was created and // false when an existing box was adopted — letting callers skip one-time // initialization for an adopted box. +// General options are ignored when adopting an existing box. The local +// runtime additionally refuses to adopt one whose capability policy differs +// from the requested one. // // On context cancellation it only frees the returned handle (like Get); it // never force-removes the box, because an adopted box may be one the caller did diff --git a/sdks/node/README.md b/sdks/node/README.md index 8bcdb8fab..cef395bc0 100644 --- a/sdks/node/README.md +++ b/sdks/node/README.md @@ -182,6 +182,12 @@ const box = new SimpleBox({ mode: 'enabled', allowNet: ['api.openai.com'], }, + advanced: { + capabilities: { + add: ['NET_ADMIN'], + drop: ['NET_RAW'], + }, + }, env: { FOO: 'bar' }, volumes: [ { hostPath: '/tmp/data', guestPath: '/data', readOnly: false } diff --git a/sdks/node/lib/index.ts b/sdks/node/lib/index.ts index e7a8886a2..ea08d6e11 100644 --- a/sdks/node/lib/index.ts +++ b/sdks/node/lib/index.ts @@ -98,6 +98,8 @@ export { BoxTunnel, NetworkHandle, type NetworkSpec, + type AdvancedBoxOptions, + type ContainerCapabilities, type SimpleBoxOptions, type SecurityOptions, type Secret, diff --git a/sdks/node/lib/native-contracts.ts b/sdks/node/lib/native-contracts.ts index 6cc70cf4f..667552989 100644 --- a/sdks/node/lib/native-contracts.ts +++ b/sdks/node/lib/native-contracts.ts @@ -121,6 +121,15 @@ export interface JsHealthCheckOptions { startPeriod: number; } +export interface JsContainerCapabilities { + add?: string[]; + drop?: string[]; +} + +export interface JsAdvancedBoxOptions { + capabilities?: JsContainerCapabilities; +} + export interface JsBoxOptions { image?: string; rootfsPath?: string; @@ -148,6 +157,7 @@ export interface JsBoxOptions { entrypoint?: string[]; cmd?: string[]; user?: string; + advanced?: JsAdvancedBoxOptions; security?: JsSecurityOptions; healthCheck?: JsHealthCheckOptions; secrets?: JsSecret[]; diff --git a/sdks/node/lib/simplebox.ts b/sdks/node/lib/simplebox.ts index a581c0143..ea95c8971 100644 --- a/sdks/node/lib/simplebox.ts +++ b/sdks/node/lib/simplebox.ts @@ -262,10 +262,24 @@ export interface SimpleBoxOptions { */ user?: string; + /** Expert-only container process options. */ + advanced?: AdvancedBoxOptions; + /** Security isolation options for the box. */ security?: SecurityOptions; } +export interface ContainerCapabilities { + /** Linux capabilities added to BoxLite's Docker-compatible baseline. */ + add?: string[]; + /** Linux capabilities removed from the resulting capability set. */ + drop?: string[]; +} + +export interface AdvancedBoxOptions { + capabilities?: ContainerCapabilities; +} + /** Box-scoped network operations for a SimpleBox. */ export class NetworkHandle { /** @internal */ @@ -405,6 +419,16 @@ export class SimpleBox { entrypoint: options.entrypoint, cmd: options.cmd, user: options.user, + advanced: options.advanced + ? { + capabilities: options.advanced.capabilities + ? { + add: [...(options.advanced.capabilities.add ?? [])], + drop: [...(options.advanced.capabilities.drop ?? [])], + } + : undefined, + } + : undefined, security, secrets: options.secrets, }; diff --git a/sdks/node/src/advanced_options.rs b/sdks/node/src/advanced_options.rs index 511ce84e2..b2d3f54c7 100644 --- a/sdks/node/src/advanced_options.rs +++ b/sdks/node/src/advanced_options.rs @@ -1,4 +1,4 @@ -use boxlite::runtime::advanced_options::{ResourceLimits, SecurityOptions}; +use boxlite::runtime::advanced_options::{ContainerCapabilities, ResourceLimits, SecurityOptions}; use napi_derive::napi; // ============================================================================ @@ -89,6 +89,34 @@ impl From for SecurityOptions { } } +/// Linux capability policy for the container process. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsContainerCapabilities { + /// Capabilities added to BoxLite's Docker-compatible baseline. + pub add: Option>, + + /// Capabilities removed from the resulting capability set. + pub drop: Option>, +} + +impl From for ContainerCapabilities { + fn from(capabilities: JsContainerCapabilities) -> Self { + Self { + add: capabilities.add.unwrap_or_default(), + drop: capabilities.drop.unwrap_or_default(), + } + } +} + +/// Expert-only box options. Released top-level security and health-check +/// fields remain on `JsBoxOptions`; new capability policy is nested here. +#[napi(object)] +#[derive(Clone, Debug)] +pub struct JsAdvancedBoxOptions { + pub capabilities: Option, +} + #[cfg(test)] mod tests { use super::*; diff --git a/sdks/node/src/info.rs b/sdks/node/src/info.rs index b5644122e..f1c24e374 100644 --- a/sdks/node/src/info.rs +++ b/sdks/node/src/info.rs @@ -148,7 +148,6 @@ impl From for JsBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; - Self { id: info.id.to_string(), name: info.name, diff --git a/sdks/node/src/lib.rs b/sdks/node/src/lib.rs index 5683d8daa..010eb8118 100644 --- a/sdks/node/src/lib.rs +++ b/sdks/node/src/lib.rs @@ -21,7 +21,7 @@ mod util; mod volumes; // Re-export all public types -pub use advanced_options::JsSecurityOptions; +pub use advanced_options::{JsAdvancedBoxOptions, JsContainerCapabilities, JsSecurityOptions}; pub use box_handle::JsBox; pub use copy::JsCopyOptions; pub use exec::{JsExecResult, JsExecStderr, JsExecStdin, JsExecStdout, JsExecution}; diff --git a/sdks/node/src/options.rs b/sdks/node/src/options.rs index ab47cc2c1..38e93152a 100644 --- a/sdks/node/src/options.rs +++ b/sdks/node/src/options.rs @@ -10,7 +10,9 @@ use boxlite::runtime::options::{ use napi::bindgen_prelude::Error; use napi_derive::napi; -use crate::advanced_options::JsSecurityOptions; +#[cfg(test)] +use crate::advanced_options::JsContainerCapabilities; +use crate::advanced_options::{JsAdvancedBoxOptions, JsSecurityOptions}; /// Health check options for boxes. /// @@ -229,6 +231,9 @@ pub struct JsBoxOptions { /// If None, uses the image's USER directive (defaults to root). pub user: Option, + /// Expert-only options introduced after the original flat API. + pub advanced: Option, + /// Security isolation options for the box. pub security: Option, @@ -408,6 +413,11 @@ impl TryFrom for BoxOptions { .unwrap_or_default(); let health_check = js_opts.health_check.map(HealthCheckOptions::from); + let capabilities = js_opts + .advanced + .and_then(|advanced| advanced.capabilities) + .map(Into::into) + .unwrap_or_default(); let secrets = js_opts .secrets .unwrap_or_default() @@ -434,6 +444,7 @@ impl TryFrom for BoxOptions { ports, auto_remove, advanced: AdvancedBoxOptions { + capabilities, security, health_check, ..Default::default() @@ -733,6 +744,7 @@ mod tests { entrypoint: None, cmd: None, user: None, + advanced: None, security: None, health_check: None, secrets: None, @@ -745,9 +757,28 @@ mod tests { assert!(!both.auto_remove); assert_eq!(both.auto_delete, Some(60)); + let mut with_capabilities = js.clone(); + with_capabilities.advanced = Some(JsAdvancedBoxOptions { + capabilities: Some(JsContainerCapabilities { + add: Some(vec!["NET_ADMIN".into(), "SYS_PTRACE".into()]), + drop: Some(vec!["MKNOD".into(), "NET_RAW".into()]), + }), + }); + let with_capabilities = BoxOptions::try_from(with_capabilities).unwrap(); + assert_eq!( + with_capabilities.advanced.capabilities.add, + ["NET_ADMIN", "SYS_PTRACE"] + ); + assert_eq!( + with_capabilities.advanced.capabilities.drop, + ["MKNOD", "NET_RAW"] + ); + let opts = BoxOptions::try_from(js).unwrap(); assert!(!opts.auto_remove); assert_eq!(opts.auto_delete, None); + assert!(opts.advanced.capabilities.add.is_empty()); + assert!(opts.advanced.capabilities.drop.is_empty()); match opts.network { NetworkSpec::Enabled { allow_net } => { assert_eq!(allow_net, vec!["example.com", "*.openai.com"]); @@ -777,6 +808,7 @@ mod tests { entrypoint: None, cmd: None, user: None, + advanced: None, security: None, health_check: None, secrets: Some(vec![JsSecret { diff --git a/sdks/node/src/runtime.rs b/sdks/node/src/runtime.rs index ac4c268a1..337156756 100644 --- a/sdks/node/src/runtime.rs +++ b/sdks/node/src/runtime.rs @@ -145,7 +145,9 @@ impl JsBoxlite { /// Returns an object with `box` (the box handle) and `created` (true if /// newly created, false if an existing box was found). /// - /// When an existing box is returned, the provided options are ignored. + /// When an existing box is returned, general options are ignored. The + /// local runtime additionally refuses to adopt a box whose capability + /// policy differs from the requested one. /// /// # Arguments /// * `options` - Box configuration (used only if creating a new box) diff --git a/sdks/node/tests/options.test.ts b/sdks/node/tests/options.test.ts index b5ed88c00..6c0017ccb 100644 --- a/sdks/node/tests/options.test.ts +++ b/sdks/node/tests/options.test.ts @@ -1,12 +1,18 @@ /** * Unit tests for SimpleBoxOptions interface (no VM required). * - * Tests the type structure and expected properties for cmd/user options. + * Tests the type structure and expected properties for box options. */ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import type { Secret, SimpleBoxOptions } from "../lib/simplebox.js"; +vi.mock("../lib/native.js", () => ({ + getJsBoxlite: () => ({ + withDefaultConfig: () => ({}), + }), +})); + describe("SimpleBoxOptions", () => { test("cmd defaults to undefined", () => { const opts: SimpleBoxOptions = {}; @@ -18,6 +24,36 @@ describe("SimpleBoxOptions", () => { expect(opts.user).toBeUndefined(); }); + test("capability policy defaults to undefined", async () => { + const { SimpleBox } = await import("../lib/simplebox.js"); + const box = new SimpleBox(); + const nativeOptions = (box as any)._boxOpts; + + expect(nativeOptions.advanced).toBeUndefined(); + }); + + test("forwards custom capability lists", async () => { + const { SimpleBox } = await import("../lib/simplebox.js"); + const box = new SimpleBox({ + advanced: { + capabilities: { + add: ["NET_ADMIN", "SYS_PTRACE"], + drop: ["MKNOD", "NET_RAW"], + }, + }, + }); + const nativeOptions = (box as any)._boxOpts; + + expect(nativeOptions.advanced.capabilities.add).toEqual([ + "NET_ADMIN", + "SYS_PTRACE", + ]); + expect(nativeOptions.advanced.capabilities.drop).toEqual([ + "MKNOD", + "NET_RAW", + ]); + }); + test("accepts cmd array", () => { const opts: SimpleBoxOptions = { image: "docker:dind", diff --git a/sdks/python/README.md b/sdks/python/README.md index af1f62049..5415926b2 100644 --- a/sdks/python/README.md +++ b/sdks/python/README.md @@ -242,6 +242,9 @@ Configuration options for creating a box. - `ports: List[Tuple[int, int, str]]` - Port forwarding as (host_port, guest_port, protocol) - Protocol: `"tcp"` or `"udp"` - `secrets: List[Secret]` - Host-side HTTP(S) secret substitution rules +- `advanced: AdvancedBoxOptions | None` - Expert-only container options + - `capabilities.add: List[str]` - Capabilities added to BoxLite's baseline + - `capabilities.drop: List[str]` - Capabilities removed from the resulting set - `auto_remove: bool` - Auto cleanup after stop (default: True) `NetworkSpec` uses: @@ -273,6 +276,12 @@ options = boxlite.BoxOptions( mode="enabled", allow_net=["api.openai.com"], ), + advanced=boxlite.AdvancedBoxOptions( + capabilities=boxlite.ContainerCapabilities( + add=["NET_ADMIN"], + drop=["NET_RAW"], + ), + ), secrets=[ boxlite.Secret( name="openai", diff --git a/sdks/python/boxlite/__init__.py b/sdks/python/boxlite/__init__.py index 786809ca9..492ee9c91 100644 --- a/sdks/python/boxlite/__init__.py +++ b/sdks/python/boxlite/__init__.py @@ -10,6 +10,7 @@ try: from .boxlite import ( AccessToken, + AdvancedBoxOptions, ApiKeyCredential, Box, BoxInfo, @@ -19,6 +20,7 @@ BoxOptions, BoxStateInfo, CloneOptions, + ContainerCapabilities, CopyOptions, ExecStderr, ExecStdout, @@ -47,6 +49,8 @@ __all__ = [ # noqa: RUF022 - grouped by API area, not alphabetical # Core Rust API "Options", + "AdvancedBoxOptions", + "ContainerCapabilities", "ImageRegistry", "BoxOptions", "BoxliteRestOptions", diff --git a/sdks/python/boxlite/sync_api/_boxlite.py b/sdks/python/boxlite/sync_api/_boxlite.py index cb96bbf38..3b7ab626a 100644 --- a/sdks/python/boxlite/sync_api/_boxlite.py +++ b/sdks/python/boxlite/sync_api/_boxlite.py @@ -263,7 +263,10 @@ def create( Create a new box. Args: - options: BoxOptions specifying image, resources, etc. + options: BoxOptions specifying image, resources, etc. General + options are ignored when a box is reused; the local runtime + additionally refuses to reuse a box whose capability policy + differs from the requested one. name: Optional unique name for the box. Returns: diff --git a/sdks/python/src/advanced_options.rs b/sdks/python/src/advanced_options.rs index acaa025d7..517dbbece 100644 --- a/sdks/python/src/advanced_options.rs +++ b/sdks/python/src/advanced_options.rs @@ -1,4 +1,6 @@ -use boxlite::runtime::advanced_options::{HealthCheckOptions, ResourceLimits, SecurityOptions}; +use boxlite::runtime::advanced_options::{ + ContainerCapabilities, HealthCheckOptions, ResourceLimits, SecurityOptions, +}; use pyo3::prelude::*; // ============================================================================ @@ -253,6 +255,44 @@ impl From for SecurityOptions { // Advanced Options // ============================================================================ +/// Linux capability policy for the container process. +#[pyclass(name = "ContainerCapabilities")] +#[derive(Clone, Debug, Default)] +pub struct PyContainerCapabilities { + /// Capabilities added to BoxLite's Docker-compatible baseline. + #[pyo3(get, set)] + pub add: Vec, + + /// Capabilities removed from the resulting capability set. + #[pyo3(get, set)] + pub drop: Vec, +} + +#[pymethods] +impl PyContainerCapabilities { + #[new] + #[pyo3(signature = (add=vec![], drop=vec![]))] + fn new(add: Vec, drop: Vec) -> Self { + Self { add, drop } + } + + fn __repr__(&self) -> String { + format!( + "ContainerCapabilities(add={:?}, drop={:?})", + self.add, self.drop + ) + } +} + +impl From for ContainerCapabilities { + fn from(capabilities: PyContainerCapabilities) -> Self { + Self { + add: capabilities.add, + drop: capabilities.drop, + } + } +} + /// Advanced options for expert users. /// /// Entry-level users can ignore this — defaults are compatibility-focused. @@ -266,19 +306,25 @@ pub struct PyAdvancedBoxOptions { /// Health check options. #[pyo3(get, set)] pub health_check: Option, + + /// Linux capability policy for the container process. + #[pyo3(get, set)] + pub capabilities: PyContainerCapabilities, } #[pymethods] impl PyAdvancedBoxOptions { #[new] - #[pyo3(signature = (security=None, health_check=None))] + #[pyo3(signature = (security=None, health_check=None, capabilities=None))] fn new( security: Option, health_check: Option, + capabilities: Option, ) -> Self { Self { security, health_check, + capabilities: capabilities.unwrap_or_default(), } } } diff --git a/sdks/python/src/info.rs b/sdks/python/src/info.rs index 240de374f..140582083 100644 --- a/sdks/python/src/info.rs +++ b/sdks/python/src/info.rs @@ -176,7 +176,6 @@ pub(crate) struct PyBoxInfo { pub(crate) cpus: u8, #[pyo3(get)] pub(crate) memory_mib: u32, - #[pyo3(get)] pub(crate) auto_pause: u32, #[pyo3(get)] pub(crate) auto_delete: u32, @@ -223,7 +222,6 @@ impl From for PyBoxInfo { failures: info.health_status.failures, last_check: info.health_status.last_check.map(|dt| dt.to_rfc3339()), }; - PyBoxInfo { id: info.id.to_string(), name: info.name, diff --git a/sdks/python/src/lib.rs b/sdks/python/src/lib.rs index 6ff730b17..267560c9e 100644 --- a/sdks/python/src/lib.rs +++ b/sdks/python/src/lib.rs @@ -14,7 +14,9 @@ mod snapshots; mod util; mod volumes; -use crate::advanced_options::{PyAdvancedBoxOptions, PyHealthCheckOptions, PySecurityOptions}; +use crate::advanced_options::{ + PyAdvancedBoxOptions, PyContainerCapabilities, PyHealthCheckOptions, PySecurityOptions, +}; use crate::box_handle::PyBox; use crate::exec::{PyExecStderr, PyExecStdin, PyExecStdout, PyExecution}; use crate::images::{PyImageHandle, PyImageInfo, PyImagePullResult}; @@ -40,6 +42,7 @@ fn boxlite_python(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/sdks/python/src/options.rs b/sdks/python/src/options.rs index 9518650c6..4f0fd2638 100644 --- a/sdks/python/src/options.rs +++ b/sdks/python/src/options.rs @@ -408,7 +408,7 @@ pub(crate) struct PyBoxOptions { #[pyo3(get, set)] pub(crate) user: Option, - /// Advanced options for expert users (security, mount isolation, health check). + /// Advanced options for expert users (capabilities, security, mount isolation, health check). #[pyo3(get, set)] pub(crate) advanced: Option, @@ -564,6 +564,7 @@ impl TryFrom for BoxOptions { if let Some(health_check) = advanced.health_check { opts.advanced.health_check = Some(HealthCheckOptions::from(health_check)); } + opts.advanced.capabilities = advanced.capabilities.into(); } // Convert Python secrets to Rust secrets diff --git a/sdks/python/tests/test_options.py b/sdks/python/tests/test_options.py index ad27cba20..4f344c2bb 100644 --- a/sdks/python/tests/test_options.py +++ b/sdks/python/tests/test_options.py @@ -36,6 +36,27 @@ def test_detach_default_is_none(self): # Python side defaults to None, Rust side defaults to False assert opts.detach is None + def test_capability_lists_default_to_empty(self): + """Test that capability overrides are empty under advanced options.""" + advanced = boxlite.AdvancedBoxOptions() + assert advanced.capabilities.add == [] + assert advanced.capabilities.drop == [] + + def test_custom_capability_lists_are_preserved(self): + """Test supplying Docker-style capability additions and removals.""" + capabilities = boxlite.ContainerCapabilities( + add=["NET_ADMIN", "SYS_PTRACE"], + drop=["MKNOD", "NET_RAW"], + ) + opts = boxlite.BoxOptions( + image="alpine:latest", + advanced=boxlite.AdvancedBoxOptions(capabilities=capabilities), + ) + assert opts.advanced.capabilities.add == ["NET_ADMIN", "SYS_PTRACE"] + assert opts.advanced.capabilities.drop == ["MKNOD", "NET_RAW"] + assert not hasattr(opts, "cap_add") + assert not hasattr(opts, "cap_drop") + def test_explicit_auto_remove_true(self): """Test setting auto_remove=True explicitly.""" opts = boxlite.BoxOptions(image="alpine:latest", auto_remove=True) diff --git a/src/boxlite/src/lib.rs b/src/boxlite/src/lib.rs index 39831bdb9..e8b578bff 100644 --- a/src/boxlite/src/lib.rs +++ b/src/boxlite/src/lib.rs @@ -49,7 +49,7 @@ pub use litebox::{ }; pub use metrics::{BoxMetrics, RuntimeMetrics}; pub use runtime::advanced_options::{ - AdvancedBoxOptions, HealthCheckOptions, ResourceLimits, SecurityOptions, + AdvancedBoxOptions, ContainerCapabilities, HealthCheckOptions, ResourceLimits, SecurityOptions, }; pub use runtime::options::{ BoxArchive, BoxOptions, BoxliteOptions, CloneOptions, ExportOptions, ImageRegistry, diff --git a/src/boxlite/src/litebox/archive.rs b/src/boxlite/src/litebox/archive.rs index 641fdce91..e2267ac21 100644 --- a/src/boxlite/src/litebox/archive.rs +++ b/src/boxlite/src/litebox/archive.rs @@ -15,20 +15,37 @@ use crate::disk::constants::filenames as disk_filenames; /// Manifest filename inside the archive. pub(crate) const MANIFEST_FILENAME: &str = "manifest.json"; -/// Current archive format version. +/// Archive format version for configurations a v3 importer reads correctly. pub(crate) const ARCHIVE_VERSION: u32 = 3; +/// First archive version that carries a custom Linux capability policy. +/// +/// A pre-capability importer accepts up to v3 and would silently drop +/// `advanced.capabilities`, starting the box with wider privileges than the +/// archive asked for. Stamping v4 makes that importer refuse the archive. +pub(crate) const CAPABILITY_POLICY_ARCHIVE_VERSION: u32 = 4; + /// Maximum archive version this build can import. -pub(crate) const MAX_SUPPORTED_VERSION: u32 = 3; +pub(crate) const MAX_SUPPORTED_VERSION: u32 = CAPABILITY_POLICY_ARCHIVE_VERSION; + +/// Pick the archive format an exported box needs. +pub(crate) fn archive_version_for_options(options: &crate::runtime::options::BoxOptions) -> u32 { + if options.advanced.capabilities.is_empty() { + ARCHIVE_VERSION + } else { + CAPABILITY_POLICY_ARCHIVE_VERSION + } +} /// Archive manifest stored as `manifest.json` inside exported archives. /// /// v1: plain tar, no checksums /// v2: tar.zst with checksums /// v3: adds `box_options` for full configuration preservation +/// v4: `box_options.advanced` carries a custom capability policy #[derive(Debug, Serialize, Deserialize)] pub struct ArchiveManifest { - /// Archive format version (1, 2, or 3). + /// Archive format version (1 through 4). pub version: u32, /// Original box name (optional, may be renamed on import). pub box_name: Option, @@ -239,6 +256,35 @@ mod tests { use super::*; use tempfile::tempdir; + /// A capability-bearing export must not be readable by an importer that + /// would drop the policy: those archives are stamped v4, ordinary ones v3. + /// + /// The literals are the compatibility boundary itself — a pre-capability + /// importer accepts up to 3 — so pin them, not just the branch. + #[test] + fn only_a_capability_policy_raises_the_archive_version() { + assert_eq!(ARCHIVE_VERSION, 3); + assert_eq!(CAPABILITY_POLICY_ARCHIVE_VERSION, 4); + + let ordinary = crate::runtime::options::BoxOptions::default(); + assert_eq!(archive_version_for_options(&ordinary), ARCHIVE_VERSION); + + let custom = crate::runtime::options::BoxOptions { + advanced: crate::runtime::advanced_options::AdvancedBoxOptions { + capabilities: crate::runtime::advanced_options::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + assert_eq!( + archive_version_for_options(&custom), + CAPABILITY_POLICY_ARCHIVE_VERSION + ); + } + #[test] fn test_extract_zstd_archive_via_magic_bytes() { let dir = tempdir().unwrap(); diff --git a/src/boxlite/src/litebox/clone_export.rs b/src/boxlite/src/litebox/clone_export.rs index 452afcd31..15fe3f8d9 100644 --- a/src/boxlite/src/litebox/clone_export.rs +++ b/src/boxlite/src/litebox/clone_export.rs @@ -287,7 +287,8 @@ fn do_export_finalize( dest: &std::path::Path, ) -> BoxliteResult { use super::archive::{ - ARCHIVE_VERSION, ArchiveManifest, MANIFEST_FILENAME, build_zstd_tar_archive, sha256_file, + ArchiveManifest, MANIFEST_FILENAME, archive_version_for_options, build_zstd_tar_archive, + sha256_file, }; let output_path = if dest.is_dir() { @@ -311,7 +312,7 @@ fn do_export_finalize( }; let manifest = ArchiveManifest { - version: ARCHIVE_VERSION, + version: archive_version_for_options(config_options), box_name: config_name.map(|s| s.to_string()), image, box_options: Some(config_options.clone()), diff --git a/src/boxlite/src/litebox/init/tasks/guest_init.rs b/src/boxlite/src/litebox/init/tasks/guest_init.rs index 574048587..75acbf292 100644 --- a/src/boxlite/src/litebox/init/tasks/guest_init.rs +++ b/src/boxlite/src/litebox/init/tasks/guest_init.rs @@ -7,84 +7,94 @@ //! the vmm_config stage. use super::{InitCtx, log_task_error, task_start}; -use crate::images::ContainerImageConfig; use crate::net::constants::{GATEWAY_IP, GUEST_CIDR, GUEST_INTERFACE}; use crate::pipeline::PipelineTask; use crate::portal::GuestSession; -use crate::portal::interfaces::{ContainerRootfsInitConfig, GuestInitConfig, NetworkInitConfig}; -use crate::runtime::options::NetworkSpec; -use crate::runtime::types::ContainerID; -use crate::volumes::{ContainerMount, GuestVolumeManager}; +use crate::portal::interfaces::{ContainerInitConfig, GuestInitConfig, NetworkInitConfig}; use async_trait::async_trait; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +/// Oldest guest release that honors `advanced.capabilities` on +/// `Container.Init`. An earlier guest drops the field and starts the container +/// with the default capability set while the caller believes a policy applied. +/// Guest rootfs images are cached per version and reused, so a host can meet +/// one long after its own release. +const MIN_CAPABILITY_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion = (0, 9, 8); + pub struct GuestInitTask; +struct GuestBootstrapConfig { + guest: GuestInitConfig, + container: ContainerInitConfig, +} + #[async_trait] impl PipelineTask for GuestInitTask { async fn run(self: Box, ctx: InitCtx) -> BoxliteResult<()> { let task_name = self.name(); let box_id = task_start(&ctx, task_name).await; - let ( - guest_session, - container_image_config, - container_id, - volume_mgr, - rootfs_init, - container_mounts, - network_spec, - ca_cert_pem, - tty, - ) = - { - let mut ctx = ctx.lock().await; - let guest_session = ctx - .guest_session - .take() - .ok_or_else(|| BoxliteError::Internal("connect task must run first".into()))?; - let container_image_config = ctx - .container_image_config - .clone() - .ok_or_else(|| BoxliteError::Internal("rootfs task must run first".into()))?; - let volume_mgr = ctx.volume_mgr.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let rootfs_init = ctx.rootfs_init.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let container_mounts = ctx.container_mounts.take().ok_or_else(|| { - BoxliteError::Internal("vmm_spawn task must run first".into()) - })?; - let network_spec = ctx.config.options.network.clone(); - let ca_cert_pem = ctx.ca_cert_pem.clone(); - let tty = ctx.config.options.tty; - ( - guest_session, - container_image_config, - ctx.config.container.id.clone(), - volume_mgr, - rootfs_init, - container_mounts, - network_spec, - ca_cert_pem, - tty, - ) + let (guest_session, volume_mgr, rootfs_init, container_mounts, bootstrap) = { + let mut ctx = ctx.lock().await; + let guest_session = ctx + .guest_session + .take() + .ok_or_else(|| BoxliteError::Internal("connect task must run first".into()))?; + let image = ctx + .container_image_config + .clone() + .ok_or_else(|| BoxliteError::Internal("rootfs task must run first".into()))?; + let volume_mgr = ctx + .volume_mgr + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + let rootfs_init = ctx + .rootfs_init + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + let container_mounts = ctx + .container_mounts + .take() + .ok_or_else(|| BoxliteError::Internal("vmm_spawn task must run first".into()))?; + + let network = match &ctx.config.options.network { + crate::runtime::options::NetworkSpec::Enabled { .. } => Some(NetworkInitConfig { + interface: GUEST_INTERFACE.to_string(), + ip: Some(GUEST_CIDR.to_string()), + gateway: Some(GATEWAY_IP.to_string()), + }), + crate::runtime::options::NetworkSpec::Disabled => None, }; + let bootstrap = GuestBootstrapConfig { + guest: GuestInitConfig { + volumes: volume_mgr.build_guest_mounts(), + network, + }, + container: ContainerInitConfig { + container_id: ctx.config.container.id.as_str().to_owned(), + image, + rootfs: rootfs_init.clone(), + mounts: container_mounts.clone(), + ca_certs: ctx.ca_cert_pem.iter().cloned().collect(), + tty: ctx.config.options.tty, + advanced: crate::portal::interfaces::container::ContainerAdvancedConfig { + capabilities: ctx.config.options.advanced.capabilities.clone(), + }, + }, + }; + + ( + guest_session, + volume_mgr, + rootfs_init, + container_mounts, + bootstrap, + ) + }; - run_guest_init( - guest_session.clone(), - &container_image_config, - &container_id, - &volume_mgr, - &rootfs_init, - &container_mounts, - &network_spec, - ca_cert_pem.as_deref(), - tty, - ) - .await - .inspect_err(|e| log_task_error(&box_id, task_name, e))?; + run_guest_init(guest_session.clone(), bootstrap) + .await + .inspect_err(|e| log_task_error(&box_id, task_name, e))?; let mut ctx = ctx.lock().await; ctx.guest_session = Some(guest_session); @@ -101,58 +111,26 @@ impl PipelineTask for GuestInitTask { } /// Initialize the guest and create the container (init is *not* run here). -#[allow(clippy::too_many_arguments)] async fn run_guest_init( guest_session: GuestSession, - container_image_config: &ContainerImageConfig, - container_id: &ContainerID, - volume_mgr: &GuestVolumeManager, - rootfs_init: &ContainerRootfsInitConfig, - container_mounts: &[ContainerMount], - network_spec: &NetworkSpec, - ca_cert_pem: Option<&str>, - tty: bool, + bootstrap: GuestBootstrapConfig, ) -> BoxliteResult<()> { - let container_id_str = container_id.as_str(); - - // Build guest volumes from volume manager - let guest_volumes = volume_mgr.build_guest_mounts(); - - let network = match network_spec { - NetworkSpec::Enabled { .. } => Some(NetworkInitConfig { - interface: GUEST_INTERFACE.to_string(), - ip: Some(GUEST_CIDR.to_string()), - gateway: Some(GATEWAY_IP.to_string()), - }), - NetworkSpec::Disabled => None, - }; - - let guest_init_config = GuestInitConfig { - volumes: guest_volumes, - network, - }; - // Step 1: Guest Init (volumes + network) tracing::info!("Sending guest initialization request"); let mut guest_interface = guest_session.guest().await?; - guest_interface.init(guest_init_config).await?; + if !bootstrap.container.advanced.capabilities.is_empty() { + guest_interface + .require_min_version(MIN_CAPABILITY_GUEST_VERSION) + .await?; + } + guest_interface.init(bootstrap.guest).await?; tracing::info!("Guest initialized successfully"); // Step 2: create the container (rootfs + image config + user mounts). This // does NOT run init — Container.Init only creates. tracing::info!("Sending container configuration to guest"); let mut container_interface = guest_session.container().await?; - let ca_certs: Vec = ca_cert_pem.into_iter().map(|s| s.to_string()).collect(); - let returned_id = container_interface - .init( - container_id_str, - container_image_config.clone(), - rootfs_init.clone(), - container_mounts.to_vec(), - ca_certs, - tty, - ) - .await?; + let returned_id = container_interface.init(bootstrap.container).await?; tracing::info!(container_id = %returned_id, "Container created"); // Running init is deliberately *not* done here. The container is created and diff --git a/src/boxlite/src/portal/interfaces/container.rs b/src/boxlite/src/portal/interfaces/container.rs index 042bf4bdf..47a1449fd 100644 --- a/src/boxlite/src/portal/interfaces/container.rs +++ b/src/boxlite/src/portal/interfaces/container.rs @@ -1,12 +1,16 @@ //! Container service interface. use boxlite_shared::{ - BindMount, BoxliteError, BoxliteResult, CaCert, ContainerClient, + BindMount, BoxliteError, BoxliteResult, CaCert, + ContainerAdvancedOptions as ProtoContainerAdvancedOptions, + ContainerCapabilities as ProtoContainerCapabilities, ContainerClient, ContainerConfig as ProtoContainerConfig, ContainerInitRequest, DiskRootfs, MergedRootfs, OverlayRootfs, RootfsInit, container_init_response, }; use tonic::transport::Channel; +use crate::images::ContainerImageConfig; +use crate::runtime::advanced_options::ContainerCapabilities; use crate::volumes::ContainerMount; /// Container rootfs initialization strategy. @@ -69,6 +73,25 @@ impl ContainerRootfsInitConfig { } } +/// Everything needed to create one container in the guest. +/// +/// Keeping this as a request object makes the host-to-guest boundary explicit +/// and prevents each new container option from widening [`ContainerInterface::init`]. +pub struct ContainerInitConfig { + pub container_id: String, + pub image: ContainerImageConfig, + pub rootfs: ContainerRootfsInitConfig, + pub mounts: Vec, + pub ca_certs: Vec, + pub tty: bool, + pub advanced: ContainerAdvancedConfig, +} + +/// Expert-only options crossing the host-to-guest container boundary. +pub struct ContainerAdvancedConfig { + pub capabilities: ContainerCapabilities, +} + /// Container service interface. pub struct ContainerInterface { client: ContainerClient, @@ -86,30 +109,31 @@ impl ContainerInterface { /// init; call [`Self::start`] for that. Creation and start are separate so /// the host can attach to the main command in between. Returns the /// container id on success. - /// - /// # Arguments - /// * `container_id` - Container ID (generated by host) - /// * `image_config` - Image-derived container config (entrypoint, env, workdir) - /// * `rootfs` - Rootfs initialization strategy - /// * `mounts` - Bind mounts from guest VM paths into container - /// * `tty` - give init a PTY rather than pipes (OCI `process.terminal`) - pub async fn init( - &mut self, - container_id: &str, - image_config: crate::images::ContainerImageConfig, - rootfs: ContainerRootfsInitConfig, - mounts: Vec, - ca_certs: Vec, - tty: bool, - ) -> BoxliteResult { + pub async fn init(&mut self, config: ContainerInitConfig) -> BoxliteResult { + let ContainerInitConfig { + container_id, + image, + rootfs, + mounts, + ca_certs, + tty, + advanced, + } = config; + let proto_config = ProtoContainerConfig { - entrypoint: image_config.final_cmd(), - env: image_config.env.clone(), - workdir: image_config.working_dir.clone(), - user: image_config.user.clone(), + entrypoint: image.final_cmd(), + env: image.env.clone(), + workdir: image.working_dir.clone(), + user: image.user.clone(), // Not an image property: `run -t` decides it, and init is the // process it applies to (OCI `process.terminal`). tty, + advanced: Some(ProtoContainerAdvancedOptions { + capabilities: Some(ProtoContainerCapabilities { + add: advanced.capabilities.add, + drop: advanced.capabilities.drop, + }), + }), }; // Convert ContainerMount to proto BindMount @@ -129,18 +153,19 @@ impl ContainerInterface { tracing::debug!(container_id = %container_id, "Sending ContainerInit request"); tracing::trace!( container_id = %container_id, - entrypoint = ?image_config.entrypoint, - cmd = ?image_config.cmd, - user = %image_config.user, - workdir = %image_config.working_dir, - env_count = image_config.env.len(), + entrypoint = ?image.entrypoint, + cmd = ?image.cmd, + user = %image.user, + workdir = %image.working_dir, + env_count = image.env.len(), + advanced = ?proto_config.advanced, rootfs = ?rootfs, mounts_count = proto_mounts.len(), "Container configuration" ); let request = ContainerInitRequest { - container_id: container_id.to_string(), + container_id: container_id.clone(), container_config: Some(proto_config), rootfs: Some(rootfs.into_proto()), mounts: proto_mounts, @@ -148,10 +173,15 @@ impl ContainerInterface { // Init's session id = container_id. The host declares it here so // `LiteBox::attach()` can address the main command with the same id // it sent, instead of both sides separately hard-coding it. - execution_id: container_id.to_string(), + execution_id: container_id.clone(), }; - let response = self.client.init(request).await?.into_inner(); + let response = self + .client + .init(request) + .await + .map_err(map_container_init_status)? + .into_inner(); match response.result { Some(container_init_response::Result::Success(success)) => { @@ -224,6 +254,13 @@ impl ContainerInterface { } } +fn map_container_init_status(status: tonic::Status) -> BoxliteError { + if status.code() == tonic::Code::InvalidArgument { + return BoxliteError::InvalidArgument(status.message().to_owned()); + } + status.into() +} + #[cfg(test)] mod tests { use super::*; @@ -235,6 +272,16 @@ mod tests { use tonic::transport::{Endpoint, Server}; use tonic::{Request, Response, Status}; + #[test] + fn container_init_preserves_invalid_argument_status() { + let error = map_container_init_status(Status::invalid_argument( + "unknown Linux capability 'CAP_FUTURE'", + )); + + assert!(matches!(error, BoxliteError::InvalidArgument(_))); + assert_eq!(error.http().0, 400); + } + /// How the stub guest answers `Container.Start`. #[derive(Clone, Copy)] enum StartReply { diff --git a/src/boxlite/src/portal/interfaces/guest.rs b/src/boxlite/src/portal/interfaces/guest.rs index 03e44673d..d0ab3f811 100644 --- a/src/boxlite/src/portal/interfaces/guest.rs +++ b/src/boxlite/src/portal/interfaces/guest.rs @@ -68,6 +68,17 @@ impl GuestInterface { Ok(()) } + /// Fail before initialization if the guest predates a required field. + /// + /// A guest built before the field existed decodes it as an unknown proto + /// field and drops it, so the request would appear to succeed while the + /// setting was never applied. The guest reports its own build version; + /// gate on that instead of letting the request through. + pub async fn require_min_version(&mut self, minimum: GuestVersion) -> BoxliteResult<()> { + let response = self.client.ping(PingRequest {}).await?.into_inner(); + ensure_guest_version(&response.version, minimum) + } + /// Shutdown the guest agent. pub async fn shutdown(&mut self) -> BoxliteResult<()> { let _response = self.client.shutdown(ShutdownRequest {}).await?; @@ -93,6 +104,77 @@ impl GuestInterface { } } +/// A guest agent version as `(major, minor, patch)`. +pub type GuestVersion = (u32, u32, u32); + +/// Parse the `major.minor.patch` core of a guest version. +/// +/// Any pre-release or build suffix is ignored, so a `0.9.8-rc1` guest counts +/// as 0.9.8: it is built from the tree that carries the field. Anything that +/// does not parse yields `None` and is rejected by the caller. +fn parse_guest_version(version: &str) -> Option { + let mut parts = version.split(['-', '+']).next()?.split('.'); + let mut next = || parts.next()?.parse::().ok(); + let parsed = (next()?, next()?, next()?); + parts.next().is_none().then_some(parsed) +} + +fn ensure_guest_version(version: &str, minimum: GuestVersion) -> BoxliteResult<()> { + let (major, minor, patch) = minimum; + let Some(reported) = parse_guest_version(version) else { + return Err(BoxliteError::Unsupported(format!( + "guest reported an unrecognized version '{version}'; {major}.{minor}.{patch} or newer is required" + ))); + }; + + if reported >= minimum { + return Ok(()); + } + + Err(BoxliteError::Unsupported(format!( + "guest {version} is older than the required {major}.{minor}.{patch}; recreate the box with the current runtime" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMUM: GuestVersion = (0, 9, 8); + + #[test] + fn guest_older_than_the_minimum_is_rejected() { + for version in ["0.9.7", "0.8.9", "0.9.7-rc1"] { + let error = ensure_guest_version(version, MINIMUM) + .expect_err("an old guest must not silently drop the field"); + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert!( + error.to_string().contains(version) && error.to_string().contains("0.9.8"), + "error should name both versions: {error}" + ); + } + } + + #[test] + fn guest_at_or_above_the_minimum_is_accepted() { + for version in ["0.9.8", "0.9.9", "0.10.0", "1.0.0", "0.9.8-rc1"] { + ensure_guest_version(version, MINIMUM) + .unwrap_or_else(|error| panic!("{version} should be accepted: {error}")); + } + } + + #[test] + fn unparseable_guest_version_is_rejected() { + for version in ["", "0.9", "0.9.8.1", "abc", "0.x.8"] { + let error = ensure_guest_version(version, MINIMUM) + .expect_err("an unreadable version must fail closed"); + + assert!(matches!(error, BoxliteError::Unsupported(_))); + } + } +} + /// Configuration for guest initialization. #[derive(Debug)] pub struct GuestInitConfig { diff --git a/src/boxlite/src/portal/interfaces/mod.rs b/src/boxlite/src/portal/interfaces/mod.rs index d4eba8bf5..6c952c010 100644 --- a/src/boxlite/src/portal/interfaces/mod.rs +++ b/src/boxlite/src/portal/interfaces/mod.rs @@ -7,7 +7,7 @@ pub mod exec; pub mod files; pub mod guest; -pub use container::{ContainerInterface, ContainerRootfsInitConfig}; +pub use container::{ContainerInitConfig, ContainerInterface, ContainerRootfsInitConfig}; pub use exec::ExecutionInterface; pub use files::FilesInterface; pub use guest::{GuestInitConfig, GuestInterface, NetworkInitConfig, VolumeConfig}; diff --git a/src/boxlite/src/rest/client.rs b/src/boxlite/src/rest/client.rs index d9effff4f..2266dbaa4 100644 --- a/src/boxlite/src/rest/client.rs +++ b/src/boxlite/src/rest/client.rs @@ -458,12 +458,16 @@ impl ApiClient { } } - let config: ServerConfig = self.get_root("/config").await?; + let config = self.fetch_config().await?; let mut cache = self.config_cache.write().await; *cache = Some(config.clone()); Ok(config) } + async fn fetch_config(&self) -> BoxliteResult { + self.get_root("/config").await + } + /// `GET /v1/me` — identity of the calling credential. Not cached /// (identity is per-credential and cheap; unlike static capabilities). /// A 404 surfaces as `BoxliteError::NotFound` (server without `/v1/me`); @@ -482,6 +486,22 @@ impl ApiClient { ensure_capability("snapshots", capabilities.snapshots_enabled) } + pub async fn require_linux_capabilities_enabled(&self) -> BoxliteResult<()> { + // This gate protects a security policy, so a cached positive response + // is insufficient after a server rollback or replacement. Recheck the + // live endpoint immediately before every capability-bearing create. + let config = self.fetch_config().await?; + let capabilities = config.capabilities.ok_or_else(|| { + BoxliteError::Unsupported( + "Remote server did not advertise Linux capabilities support".to_string(), + ) + })?; + ensure_capability( + "Linux capabilities", + capabilities.linux_capabilities_enabled, + ) + } + pub async fn require_clone_enabled(&self) -> BoxliteResult<()> { let config = self.get_config().await?; let capabilities = config.capabilities.ok_or_else(|| { @@ -732,6 +752,43 @@ mod tests { assert_eq!(client.current_bearer().await.unwrap(), None); } + #[tokio::test] + async fn linux_capability_gate_rechecks_uncached_server_config() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + for body in [ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + r#"{"capabilities":{}}"#, + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + }); + + let client = + ApiClient::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + client.require_linux_capabilities_enabled().await.unwrap(); + let second = client.require_linux_capabilities_enabled().await; + server.abort(); + + assert!(matches!(second, Err(BoxliteError::Unsupported(_)))); + } + #[test] fn flat_nest_error_response_maps_by_code() { let parsed: FlatErrorResponse = serde_json::from_str( diff --git a/src/boxlite/src/rest/runtime.rs b/src/boxlite/src/rest/runtime.rs index f208c707f..08898de0d 100644 --- a/src/boxlite/src/rest/runtime.rs +++ b/src/boxlite/src/rest/runtime.rs @@ -97,6 +97,13 @@ impl RuntimeBackend for RestRuntime { auto_resume: options.auto_resume.unwrap_or(true), } .validate()?; + + // A server that does not advertise the capability policy would accept + // the request and drop the field, silently granting default privileges. + if !options.advanced.capabilities.is_empty() { + self.client.require_linux_capabilities_enabled().await?; + } + let req = CreateBoxRequest::from_options(&options, name); let resp: BoxResponse = self.client.post("/boxes", &req).await?; let info = resp.to_box_info()?; @@ -121,7 +128,7 @@ impl RuntimeBackend for RestRuntime { } async fn get(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{}", id_or_name); + let path = format!("/boxes/{id_or_name}"); match self.client.get::(&path).await { Ok(resp) => { let info = resp.to_box_info()?; @@ -134,7 +141,7 @@ impl RuntimeBackend for RestRuntime { } async fn get_info(&self, id_or_name: &str) -> BoxliteResult> { - let path = format!("/boxes/{}", id_or_name); + let path = format!("/boxes/{id_or_name}"); match self.client.get::(&path).await { Ok(resp) => Ok(Some(resp.to_box_info()?)), Err(BoxliteError::NotFound(_)) => Ok(None), @@ -144,7 +151,7 @@ impl RuntimeBackend for RestRuntime { async fn list_info(&self) -> BoxliteResult> { let resp: ListBoxesResponse = self.client.get("/boxes").await?; - resp.boxes.iter().map(|b| b.to_box_info()).collect() + resp.boxes.iter().map(BoxResponse::to_box_info).collect() } async fn exists(&self, id_or_name: &str) -> BoxliteResult { @@ -233,6 +240,53 @@ fn runtime_metrics_from_response(resp: &RuntimeMetricsResponse) -> RuntimeMetric #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + const BOX_RESPONSE: &str = r#"{"box_id":"01HJK4TNRPQSXYZ8WM6NCVT9R5","name":"named","status":"configured","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","pid":null,"image":"alpine:latest","cpus":2,"memory_mib":512,"labels":{}}"#; + + fn capability_options() -> BoxOptions { + BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + drop: vec!["NET_RAW".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + } + } + + async fn json_server(bodies: Vec<&'static str>) -> (u16, tokio::task::JoinHandle>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let mut requests = Vec::new(); + for body in bodies { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + headers.push(socket.read_u8().await.unwrap()); + } + let request = String::from_utf8(headers).unwrap(); + requests.push(request.lines().next().unwrap().to_string()); + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ) + .await + .unwrap(); + } + requests + }); + (port, server) + } #[tokio::test] async fn test_import_box_requires_capability() { @@ -278,6 +332,81 @@ mod tests { ); } + #[tokio::test] + async fn custom_capabilities_require_server_advertisement_before_create() { + // A server that does not advertise the feature would accept the create + // request and drop `advanced`, silently granting default privileges. + let (port, server) = json_server(vec![r#"{"capabilities":{}}"#]).await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + + let error = match RuntimeBackend::create(&runtime, capability_options(), None).await { + Err(error) => error, + Ok(_) => panic!("an old server must not silently ignore a capability policy"), + }; + + assert!(matches!(error, BoxliteError::Unsupported(_))); + assert_eq!(server.await.unwrap(), ["GET /v1/config HTTP/1.1"]); + } + + #[tokio::test] + async fn advertised_capability_support_creates_on_the_shared_route() { + let (port, server) = json_server(vec![ + r#"{"capabilities":{"linux_capabilities_enabled":true}}"#, + BOX_RESPONSE, + ]) + .await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + + RuntimeBackend::create(&runtime, capability_options(), None) + .await + .expect("create with a capability policy"); + + assert_eq!( + server.await.unwrap(), + ["GET /v1/config HTTP/1.1", "POST /v1/boxes HTTP/1.1"] + ); + } + + #[tokio::test] + async fn ordinary_create_does_not_probe_server_capabilities() { + let (port, server) = json_server(vec![BOX_RESPONSE]).await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + + RuntimeBackend::create(&runtime, BoxOptions::default(), None) + .await + .expect("create without a capability policy"); + + assert_eq!(server.await.unwrap(), ["POST /v1/boxes HTTP/1.1"]); + } + + /// Inspection reads the shared routes. Addressing a versioned variant here + /// would 404, and `get` maps NotFound to `Ok(None)` — so an existing box + /// would silently report as missing rather than failing loudly. + #[tokio::test] + async fn inspection_uses_the_shared_box_routes() { + let (port, server) = json_server(vec![BOX_RESPONSE, BOX_RESPONSE]).await; + let runtime = + RestRuntime::new(&BoxliteRestOptions::new(format!("http://127.0.0.1:{port}"))).unwrap(); + + let found = RuntimeBackend::get(&runtime, "named").await.expect("get"); + let info = RuntimeBackend::get_info(&runtime, "named") + .await + .expect("get_info"); + + assert!(found.is_some(), "an existing box must not read as missing"); + assert!(info.is_some(), "an existing box must expose its info"); + assert_eq!( + server.await.unwrap(), + [ + "GET /v1/boxes/named HTTP/1.1", + "GET /v1/boxes/named HTTP/1.1" + ] + ); + } + #[tokio::test] async fn create_rejects_custom_kernel_for_rest_runtime() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/boxlite/src/rest/types.rs b/src/boxlite/src/rest/types.rs index 4ae496828..f414dc474 100644 --- a/src/boxlite/src/rest/types.rs +++ b/src/boxlite/src/rest/types.rs @@ -5,10 +5,12 @@ use std::collections::HashMap; +use boxlite_shared::errors::BoxliteError; use serde::{Deserialize, Serialize}; use crate::litebox::BoxStatus; use crate::litebox::snapshot_mgr::SnapshotInfo; +use crate::runtime::advanced_options::ContainerCapabilities; use crate::runtime::options::{CloneOptions, ExportOptions, SnapshotOptions}; // ============================================================================ @@ -75,6 +77,7 @@ pub(crate) struct ServerConfig { #[allow(dead_code)] // Constructed via serde::Deserialize #[derive(Debug, Deserialize, Clone, Default)] pub(crate) struct ServerCapabilities { + pub linux_capabilities_enabled: Option, pub snapshots_enabled: Option, pub clone_enabled: Option, pub export_enabled: Option, @@ -122,6 +125,8 @@ pub(crate) struct CreateBoxRequest { #[serde(skip_serializing_if = "Option::is_none")] pub tty: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub advanced: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub auto_pause: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auto_delete: Option, @@ -177,6 +182,11 @@ impl CreateBoxRequest { secrets, detach: Some(options.detach), tty: options.tty.then_some(true), + advanced: (!options.advanced.capabilities.is_empty()).then(|| { + CreateBoxAdvancedOptions { + capabilities: options.advanced.capabilities.clone(), + } + }), // The deprecated remove-on-stop flag was never applied by the cloud // control-plane mapper. Keep remote defaults unchanged and only send // the modern lifecycle fields when callers explicitly configure them. @@ -187,6 +197,11 @@ impl CreateBoxRequest { } } +#[derive(Debug, Serialize)] +pub(crate) struct CreateBoxAdvancedOptions { + pub capabilities: ContainerCapabilities, +} + #[derive(Debug, Serialize)] pub(crate) struct CreateBoxNetworkSpec { pub mode: String, @@ -257,7 +272,6 @@ pub(crate) struct BoxResponse { impl BoxResponse { pub fn to_box_info(&self) -> boxlite_shared::errors::BoxliteResult { use crate::runtime::id::BoxID; - use boxlite_shared::errors::BoxliteError; let id = BoxID::parse(&self.box_id).ok_or_else(|| { BoxliteError::Internal(format!( @@ -584,6 +598,7 @@ mod tests { placeholder: "".into(), }]), detach: None, + advanced: None, auto_pause: Some(900), auto_delete: Some(604800), auto_resume: None, @@ -645,6 +660,35 @@ mod tests { ); } + #[test] + fn test_create_box_request_carries_container_capabilities() { + let opts = BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["SYS_ADMIN".into()], + drop: vec!["CAP_NET_RAW".into()], + }, + ..Default::default() + }, + ..Default::default() + }; + + let req = CreateBoxRequest::from_options(&opts, None); + let advanced = req.advanced.as_ref().expect("custom policy is serialized"); + assert_eq!(advanced.capabilities.add, ["SYS_ADMIN"]); + assert_eq!(advanced.capabilities.drop, ["CAP_NET_RAW"]); + + let json = serde_json::to_value(&req).expect("serialize create request"); + assert_eq!( + json["advanced"]["capabilities"], + serde_json::json!({"add": ["SYS_ADMIN"], "drop": ["CAP_NET_RAW"]}) + ); + + let defaults = CreateBoxRequest::from_options(&BoxOptions::default(), None); + let defaults_json = serde_json::to_value(defaults).expect("serialize defaults"); + assert!(defaults_json.get("advanced").is_none()); + } + #[test] #[allow(deprecated)] fn deprecated_auto_remove_does_not_change_rest_lifecycle_defaults() { @@ -902,12 +946,14 @@ mod tests { let json = r#"{ "capabilities": { "snapshots_enabled": true, + "linux_capabilities_enabled": true, "clone_enabled": false, "export_enabled": true } }"#; let resp: ServerConfig = serde_json::from_str(json).unwrap(); let caps = resp.capabilities.unwrap(); + assert_eq!(caps.linux_capabilities_enabled, Some(true)); assert_eq!(caps.snapshots_enabled, Some(true)); assert_eq!(caps.clone_enabled, Some(false)); assert_eq!(caps.export_enabled, Some(true)); diff --git a/src/boxlite/src/runtime/advanced_options.rs b/src/boxlite/src/runtime/advanced_options.rs index 0e355b33a..c19037fb7 100644 --- a/src/boxlite/src/runtime/advanced_options.rs +++ b/src/boxlite/src/runtime/advanced_options.rs @@ -1,9 +1,10 @@ //! Advanced options for expert users. //! -//! This module contains [`AdvancedBoxOptions`], [`SecurityOptions`], [`ResourceLimits`], -//! and [`SecurityOptionsBuilder`] — configuration that entry-level users can safely -//! ignore. Defaults prioritize compatibility. Direct custom-kernel boot is also -//! grouped here because it changes the VM boot contract. +//! This module contains [`AdvancedBoxOptions`], [`ContainerCapabilities`], +//! [`SecurityOptions`], [`ResourceLimits`], and [`SecurityOptionsBuilder`] — +//! configuration that entry-level users can safely ignore. Defaults prioritize +//! compatibility. Direct custom-kernel boot is also grouped here because it +//! changes the VM boot contract. use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -562,12 +563,112 @@ impl SecurityOptionsBuilder { // Advanced Options // ============================================================================ +/// Linux capability policy for the container process. +/// +/// Capability names are case-insensitive and may include the `CAP_` prefix. +/// The special value `ALL` is accepted in either list. For named conflicts an +/// explicit addition wins; with `add = ["ALL"]`, named removals win. With +/// `drop = ["ALL"]`, explicit additions form the complete resulting set. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ContainerCapabilities { + /// Capabilities to add to BoxLite's Docker-compatible baseline. + pub add: Vec, + + /// Capabilities to remove from the resulting capability set. + pub drop: Vec, +} + +impl ContainerCapabilities { + /// Whether this policy leaves the default capability set unchanged. + pub fn is_empty(&self) -> bool { + self.add.is_empty() && self.drop.is_empty() + } + + pub(crate) fn validate(&self) -> boxlite_shared::errors::BoxliteResult<()> { + validate_capability_names("advanced.capabilities.add", &self.add)?; + validate_capability_names("advanced.capabilities.drop", &self.drop) + } + + /// Check the requested policy against the one recorded for an existing box. + pub(crate) fn check_compatibility( + &self, + actual: &Self, + box_name: &str, + ) -> boxlite_shared::errors::BoxliteResult<()> { + let canonicalize = |capabilities: &[String]| { + capabilities + .iter() + .map(|capability| canonical_capability_name(capability)) + .collect::>() + }; + + if canonicalize(&self.add) == canonicalize(&actual.add) + && canonicalize(&self.drop) == canonicalize(&actual.drop) + { + return Ok(()); + } + + Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!( + "box '{box_name}' already exists with a different capability policy; reuse it \ + with the same advanced.capabilities, or create a box under a new name" + ), + )) + } +} + +/// Uppercase a capability and strip its optional `CAP_` prefix. +/// +/// Validation and reuse comparison share one canonical form so they cannot +/// disagree about whether `net_raw` and `CAP_NET_RAW` are the same policy. +fn canonical_capability_name(capability: &str) -> String { + let normalized = capability.to_ascii_uppercase(); + normalized + .strip_prefix("CAP_") + .unwrap_or(&normalized) + .to_string() +} + +fn validate_capability_names( + option: &str, + capabilities: &[String], +) -> boxlite_shared::errors::BoxliteResult<()> { + for capability in capabilities { + let name = canonical_capability_name(capability); + if name == "ALL" { + continue; + } + + if name.is_empty() { + return Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!("empty Linux capability in {option}"), + )); + } + let mut bytes = name.bytes(); + let starts_with_letter = bytes.next().is_some_and(|byte| byte.is_ascii_uppercase()); + let has_valid_tail = + bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_'); + if !starts_with_letter || !has_valid_tail { + return Err(boxlite_shared::errors::BoxliteError::InvalidArgument( + format!("malformed Linux capability in {option}: {capability}"), + )); + } + } + + Ok(()) +} + /// Advanced options for expert users. /// /// Entry-level users can ignore this — the defaults are secure and sensible. /// Only modify these if you understand the security implications. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct AdvancedBoxOptions { + /// Linux capability policy for the container process. + #[serde(default)] + pub capabilities: ContainerCapabilities, + /// Security isolation options (jailer, seccomp, namespaces, resource limits). /// /// Secure by default: the default is the fully-enabled profile diff --git a/src/boxlite/src/runtime/core.rs b/src/boxlite/src/runtime/core.rs index a2a5d2230..08532ab46 100644 --- a/src/boxlite/src/runtime/core.rs +++ b/src/boxlite/src/runtime/core.rs @@ -303,7 +303,8 @@ impl BoxliteRuntime { /// /// Returns `(LiteBox, true)` if a new box was created, or `(LiteBox, false)` /// if an existing box with the given name was found. When an existing box is - /// returned, the provided `options` are ignored (no config drift validation). + /// returned, each backend decides which requested options must be + /// compatible with the existing box. pub async fn get_or_create( &self, options: BoxOptions, diff --git a/src/boxlite/src/runtime/import.rs b/src/boxlite/src/runtime/import.rs index 3c0a3da21..e5570bd30 100644 --- a/src/boxlite/src/runtime/import.rs +++ b/src/boxlite/src/runtime/import.rs @@ -42,6 +42,8 @@ pub(crate) async fn import_box( BoxliteError::Internal(format!("Import extraction task panicked: {}", e)) })??; + let options = options_from_manifest(&manifest)?; + // Phase 2: Validate disks and install into a staging directory (blocking I/O). // The staging dir lives inside temp_dir; provision_box will rename it. let staging_dir = temp_dir.path().join("staging"); @@ -51,12 +53,6 @@ pub(crate) async fn import_box( .await .map_err(|e| BoxliteError::Internal(format!("Import install task panicked: {}", e)))??; - // Use full BoxOptions from v3+ manifest, or reconstruct from image for v1/v2. - let options = manifest.box_options.unwrap_or_else(|| BoxOptions { - rootfs: RootfsSpec::Image(manifest.image), - ..Default::default() - }); - let litebox = runtime .provision_box(staging_dir, name, options, BoxStatus::Stopped) .await?; @@ -70,6 +66,21 @@ pub(crate) async fn import_box( Ok(litebox) } +/// Read the persisted configuration, falling back to the v1/v2 image field. +/// +/// An archive is untrusted input, so its options are validated here rather +/// than after disks have been installed and box metadata persisted. +fn options_from_manifest(manifest: &ArchiveManifest) -> BoxliteResult { + let options = manifest.box_options.clone().unwrap_or_else(|| BoxOptions { + rootfs: RootfsSpec::Image(manifest.image.clone()), + ..Default::default() + }); + options.sanitize().map_err(|error| { + BoxliteError::InvalidArgument(format!("invalid archive box_options: {error}")) + })?; + Ok(options) +} + /// Extract archive, parse manifest, verify checksums. fn extract_and_validate( archive_path: &Path, @@ -186,6 +197,33 @@ mod tests { use super::*; use tempfile::TempDir; + #[test] + fn imported_capability_policy_is_validated_before_install() { + let manifest = ArchiveManifest { + version: 3, + box_name: Some("untrusted".into()), + image: "alpine:latest".into(), + box_options: Some(BoxOptions { + advanced: crate::runtime::advanced_options::AdvancedBoxOptions { + capabilities: crate::runtime::advanced_options::ContainerCapabilities { + drop: vec!["NET-ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }), + guest_disk_checksum: String::new(), + container_disk_checksum: String::new(), + exported_at: "2026-01-01T00:00:00Z".into(), + }; + + let error = options_from_manifest(&manifest) + .expect_err("malformed archived capability policy must be rejected"); + assert!(matches!(error, BoxliteError::InvalidArgument(_))); + assert!(error.to_string().contains("NET-ADMIN")); + } + #[test] fn test_validate_no_backing_references_rejects_absolute() { let dir = TempDir::new_in("/tmp").unwrap(); diff --git a/src/boxlite/src/runtime/options.rs b/src/boxlite/src/runtime/options.rs index c50bf1e18..e9103cec2 100644 --- a/src/boxlite/src/runtime/options.rs +++ b/src/boxlite/src/runtime/options.rs @@ -371,7 +371,7 @@ pub struct BoxOptions { #[serde(default = "default_detach")] pub detach: bool, - /// Advanced options for expert users (security, mount isolation). + /// Advanced options for expert users (capabilities, security, mount isolation). /// /// Defaults are secure — most users can ignore this entirely. /// See [`AdvancedBoxOptions`] for details. @@ -556,6 +556,7 @@ impl BoxOptions { /// - effective remove-on-stop (`auto_delete>0`, or deprecated `auto_remove`) /// with `detach=true` is invalid /// - `advanced.isolate_mounts=true` is only supported on Linux + /// - `advanced.capabilities` contains well-formed Linux capability names pub(crate) fn sanitize_common(&self) -> BoxliteResult<()> { if self.removes_on_stop() && self.detach { return Err(boxlite_shared::errors::BoxliteError::Config( @@ -572,6 +573,8 @@ impl BoxOptions { )); } + self.advanced.capabilities.validate()?; + Ok(()) } @@ -778,7 +781,9 @@ pub struct CloneOptions {} mod tests { use super::*; use crate::experimental::custom_kernel::{KernelFormat, KernelOptions}; - use crate::runtime::advanced_options::{SecurityOptions, SecurityOptionsBuilder}; + use crate::runtime::advanced_options::{ + ContainerCapabilities, SecurityOptions, SecurityOptionsBuilder, + }; #[test] #[allow(deprecated)] @@ -790,6 +795,129 @@ mod tests { "auto_remove should keep its legacy default" ); assert!(!opts.detach, "detach should default to false"); + assert!( + opts.advanced.capabilities.is_empty(), + "advanced capabilities should default to empty" + ); + } + + #[test] + fn box_options_capabilities_serde_roundtrip() { + let json = r#"{ + "advanced": { + "capabilities": { + "add": ["SYS_ADMIN", "CAP_NET_ADMIN"], + "drop": ["NET_RAW"] + } + } + }"#; + + let opts: BoxOptions = serde_json::from_str(json).unwrap(); + assert_eq!( + opts.advanced.capabilities.add, + ["SYS_ADMIN", "CAP_NET_ADMIN"] + ); + assert_eq!(opts.advanced.capabilities.drop, ["NET_RAW"]); + + let serialized = serde_json::to_string(&opts).unwrap(); + let roundtripped: BoxOptions = serde_json::from_str(&serialized).unwrap(); + assert_eq!( + roundtripped.advanced.capabilities, + opts.advanced.capabilities + ); + } + + #[test] + fn box_options_sanitize_accepts_valid_capability_names() { + let opts = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["sys_admin".into(), "CAP_NET_ADMIN".into()], + drop: vec!["NET_RAW".into()], + }, + ..Default::default() + }, + ..Default::default() + }; + + opts.sanitize() + .expect("Docker-style capability names should be accepted"); + } + + #[test] + fn box_options_sanitize_accepts_future_capability_names() { + let opts = BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["FUTURE_KERNEL_FEATURE".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + opts.sanitize() + .expect("the guest runtime, not the host SDK, owns the supported capability list"); + } + + #[test] + fn box_options_sanitize_rejects_malformed_capability_names() { + for opts in [ + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + drop: vec!["NET-ADMIN".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["123".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + BoxOptions { + advanced: AdvancedBoxOptions { + capabilities: ContainerCapabilities { + add: vec!["ß".into()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }, + ] { + let err = opts + .sanitize() + .expect_err("malformed capability should be rejected"); + assert_eq!(err.http().0, 400); + let err = err.to_string(); + assert!( + err.contains("empty") + || err.contains("NET-ADMIN") + || err.contains("123") + || err.contains("ß"), + "error should identify the malformed capability, got: {err}" + ); + } } #[test] diff --git a/src/boxlite/src/runtime/rt_impl.rs b/src/boxlite/src/runtime/rt_impl.rs index 6d45fb56e..43994ca46 100644 --- a/src/boxlite/src/runtime/rt_impl.rs +++ b/src/boxlite/src/runtime/rt_impl.rs @@ -357,7 +357,8 @@ impl RuntimeImpl { /// /// Returns `(LiteBox, true)` if a new box was created, or `(LiteBox, false)` /// if an existing box with the given name was found. When an existing box is - /// returned, the provided `options` are ignored (no config drift validation). + /// returned, general options are ignored, but its capability policy must + /// match exactly so reuse cannot silently weaken or elevate privileges. pub async fn get_or_create( self: &Arc, options: BoxOptions, @@ -402,8 +403,7 @@ impl RuntimeImpl { && let Some((config, state)) = self.box_manager.lookup_box(name)? { return if reuse_existing { - let (box_impl, _) = self.get_or_create_box_impl(config, state); - Ok((litebox_from_impl(box_impl), false)) + self.adopt_existing_box(&options, config, state) } else { Err(BoxliteError::InvalidArgument(format!( "box with name '{}' already exists", @@ -442,8 +442,7 @@ impl RuntimeImpl { && let Some(ref name) = name && let Some((config, state)) = self.box_manager.lookup_box(name)? { - let (box_impl, _) = self.get_or_create_box_impl(config, state); - return Ok((litebox_from_impl(box_impl), false)); + return self.adopt_existing_box(&options, config, state); } return Err(e); @@ -472,6 +471,33 @@ impl RuntimeImpl { Ok((litebox_from_impl(box_impl), true)) } + /// Adopt a box found either by the initial lookup or duplicate-create recovery. + fn adopt_existing_box( + self: &Arc, + requested: &BoxOptions, + config: BoxConfig, + state: BoxState, + ) -> BoxliteResult<(LiteBox, bool)> { + Self::check_options_compatibility(requested, &config)?; + let (box_impl, _) = self.get_or_create_box_impl(config, state); + Ok((litebox_from_impl(box_impl), false)) + } + + /// Reject reuse when the request disagrees with the box's stored options. + /// + /// Only the capability policy is compared: silently adopting a box whose + /// privileges differ from the request is the case that matters for safety. + fn check_options_compatibility( + requested: &BoxOptions, + actual: &BoxConfig, + ) -> BoxliteResult<()> { + let box_name = actual.name.as_deref().unwrap_or_else(|| actual.id.as_str()); + requested + .advanced + .capabilities + .check_compatibility(&actual.options.advanced.capabilities, box_name) + } + /// Get a handle to an existing box by ID or name. /// /// Returns a LiteBox handle that can be used to operate on the box. @@ -1807,6 +1833,43 @@ mod tests { assert!(reject_local_lifecycle_policy(&options).is_ok()); } + #[test] + fn options_compatibility_normalizes_capability_names() { + let mut actual = test_box_config(false); + actual.options.advanced.capabilities.add = + vec!["NET_ADMIN".to_string(), "CAP_SYS_ADMIN".to_string()]; + let requested = BoxOptions { + advanced: crate::AdvancedBoxOptions { + capabilities: crate::ContainerCapabilities { + add: vec!["sys_admin".to_string(), "CAP_NET_ADMIN".to_string()], + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + assert!(RuntimeImpl::check_options_compatibility(&requested, &actual).is_ok()); + } + + #[tokio::test] + async fn get_or_create_rejects_incompatible_existing_options() { + let (runtime, _dir) = create_test_runtime(); + let mut config = test_box_config_in_layout(false, &runtime); + config.name = Some("existing".to_string()); + config.options.advanced.capabilities.drop = vec!["NET_RAW".to_string()]; + runtime + .box_manager + .add_box(&config, &BoxState::new()) + .unwrap(); + + let result = runtime + .get_or_create(BoxOptions::default(), Some("existing".to_string())) + .await; + + assert!(matches!(result, Err(BoxliteError::InvalidArgument(_)))); + } + #[tokio::test] async fn local_option_validation_uses_injected_features() { let mut options = BoxOptions::default(); diff --git a/src/cli/README.md b/src/cli/README.md index f0b4d40ff..406d9e669 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -320,6 +320,8 @@ silently restarting it, because restarting would run the command a second time. | `--volume VOLUME` | `-v` | Mount a volume (e.g. `hostPath:boxPath`, `boxPath` for anonymous) | | `--cpus N` | | CPU limit | | `--memory MiB` | | Memory limit (MiB) | +| `--cap-add CAPABILITY` | | Add a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | +| `--cap-drop CAPABILITY` | | Drop a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | | `--name NAME` | | Name the box | | `--detach` | `-d` | Run in background, print box ID | | `--rm` | | Remove the box when it exits | @@ -332,6 +334,7 @@ boxlite run -it --rm alpine:latest /bin/sh boxlite run -d --name openclaw -p 18789:18789 ghcr.io/openclaw/openclaw:main boxlite run -v /host/data:/app/data alpine:latest cat /app/data/hello.txt boxlite run --rootfs /path/to/rootfs /bin/sh +boxlite run --cap-drop ALL --cap-add NET_BIND_SERVICE nginx:alpine ``` ### `boxlite create` @@ -359,6 +362,8 @@ default, and `exec` still starts it on demand. | `--volume VOLUME` | `-v` | Mount a volume (e.g. `hostPath:boxPath`, or box path for anonymous) | | `--cpus N` | | CPU limit | | `--memory MiB` | | Memory limit (MiB) | +| `--cap-add CAPABILITY` | | Add a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | +| `--cap-drop CAPABILITY` | | Drop a Linux capability (repeatable; accepts `CAP_` prefix or `ALL`) | | `--detach` | `-d` | (create always “detaches”) | | `--rm` | | Auto-remove when stopped | @@ -368,6 +373,7 @@ default, and `exec` still starts it on demand. boxlite create --name mybox alpine:latest boxlite create -p 18789:18789 -v /data:/app/data --name openclaw ghcr.io/openclaw/openclaw:main boxlite create --rootfs /path/to/rootfs --name local-rootfs +boxlite create --cap-drop NET_RAW --name hardened alpine:latest boxlite start mybox boxlite start openclaw ``` diff --git a/src/cli/src/cli.rs b/src/cli/src/cli.rs index 2a607d4ad..43c526e0b 100644 --- a/src/cli/src/cli.rs +++ b/src/cli/src/cli.rs @@ -466,6 +466,28 @@ impl ProcessFlags { } } +// ============================================================================ +// CAPABILITY FLAGS +// ============================================================================ + +#[derive(Args, Debug, Clone, Default)] +pub struct CapabilityFlags { + /// Add a Linux capability to the container (repeatable; `ALL` is supported) + #[arg(long = "cap-add", value_name = "CAPABILITY")] + pub cap_add: Vec, + + /// Drop a Linux capability from the container (repeatable; `ALL` is supported) + #[arg(long = "cap-drop", value_name = "CAPABILITY")] + pub cap_drop: Vec, +} + +impl CapabilityFlags { + pub fn apply_to(&self, opts: &mut BoxOptions) { + opts.advanced.capabilities.add.clone_from(&self.cap_add); + opts.advanced.capabilities.drop.clone_from(&self.cap_drop); + } +} + // ============================================================================ // RESOURCE FLAGS // ============================================================================ diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index 8cc941508..a130f8278 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -1,5 +1,6 @@ use crate::cli::{ - GlobalFlags, KernelFlags, NetworkFlags, PublishFlags, ResourceFlags, VolumeFlags, + CapabilityFlags, GlobalFlags, KernelFlags, NetworkFlags, PublishFlags, ResourceFlags, + VolumeFlags, }; use boxlite::{BoxOptions, RootfsSpec}; use clap::Args; @@ -34,6 +35,9 @@ pub struct CreateArgs { #[command(flatten)] pub resource: ResourceFlags, + #[command(flatten)] + pub capability: CapabilityFlags, + #[command(flatten)] pub publish: PublishFlags, @@ -67,6 +71,7 @@ impl CreateArgs { self.boot.require_enabled(global.experimental_features())?; let mut options = BoxOptions::default(); self.resource.apply_to(&mut options); + self.capability.apply_to(&mut options); self.boot.apply_to(&mut options); self.management.apply_to(&mut options)?; self.publish.apply_to(&mut options)?; @@ -159,4 +164,27 @@ mod tests { assert!(err.to_string().contains("either IMAGE or --rootfs")); } + + #[test] + fn create_capability_flags_reach_box_options() { + let cli = Cli::try_parse_from([ + "boxlite", + "create", + "--cap-add", + "SYS_ADMIN", + "--cap-drop", + "CAP_NET_RAW", + "alpine", + ]) + .expect("capability flags should parse"); + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + let opts = args + .to_box_options(&cli.global) + .expect("options should build"); + assert_eq!(opts.advanced.capabilities.add, vec!["SYS_ADMIN"]); + assert_eq!(opts.advanced.capabilities.drop, vec!["CAP_NET_RAW"]); + } } diff --git a/src/cli/src/commands/inspect.rs b/src/cli/src/commands/inspect.rs index 91bedec08..ff5f78513 100644 --- a/src/cli/src/commands/inspect.rs +++ b/src/cli/src/commands/inspect.rs @@ -224,3 +224,35 @@ fn write_inspect_output( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use boxlite::{BoxID, BoxStatus, HealthStatus}; + use std::collections::HashMap; + + #[test] + fn inspect_omits_advanced_capability_metadata() { + let now = chrono::Utc::now(); + let info = BoxInfo { + id: BoxID::parse("inspect-capabilities").unwrap(), + name: Some("cap-box".into()), + status: BoxStatus::Configured, + created_at: now, + last_updated: now, + pid: None, + image: "alpine:latest".into(), + cpus: 1, + memory_mib: 512, + labels: HashMap::new(), + auto_pause: 0, + auto_delete: 0, + auto_resume: true, + health_status: HealthStatus::new(), + exit_code: None, + }; + + let value = serde_json::to_value(InspectPresenter::from(&info)).unwrap(); + assert!(value.get("Advanced").is_none()); + } +} diff --git a/src/cli/src/commands/run.rs b/src/cli/src/commands/run.rs index 3c3ebb50f..c9762efd4 100644 --- a/src/cli/src/commands/run.rs +++ b/src/cli/src/commands/run.rs @@ -1,6 +1,6 @@ use crate::cli::{ - GlobalFlags, KernelFlags, ManagementFlags, NetworkFlags, ProcessFlags, PublishFlags, - ResourceFlags, VolumeFlags, + CapabilityFlags, GlobalFlags, KernelFlags, ManagementFlags, NetworkFlags, ProcessFlags, + PublishFlags, ResourceFlags, VolumeFlags, }; use crate::terminal::StreamManager; use crate::util::to_shell_exit_code; @@ -16,6 +16,9 @@ pub struct RunArgs { #[command(flatten)] pub resource: ResourceFlags, + #[command(flatten)] + pub capability: CapabilityFlags, + #[command(flatten)] pub publish: PublishFlags, @@ -125,6 +128,7 @@ impl BoxRunner { ) -> anyhow::Result { let mut options = BoxOptions::default(); self.args.resource.apply_to(&mut options); + self.args.capability.apply_to(&mut options); self.args.boot.apply_to(&mut options); self.args.management.apply_to(&mut options)?; self.args.publish.apply_to(&mut options)?; @@ -194,6 +198,28 @@ mod tests { use crate::cli::{Cli, Commands}; use clap::Parser; + #[test] + fn run_capability_flags_are_repeatable() { + let cli = Cli::try_parse_from([ + "boxlite", + "run", + "--cap-add", + "SYS_ADMIN", + "--cap-add", + "NET_ADMIN", + "--cap-drop", + "CAP_NET_RAW", + "alpine", + ]) + .expect("capability flags should parse"); + let Commands::Run(args) = cli.command else { + panic!("expected run command"); + }; + + assert_eq!(args.capability.cap_add, vec!["SYS_ADMIN", "NET_ADMIN"]); + assert_eq!(args.capability.cap_drop, vec!["CAP_NET_RAW"]); + } + #[test] fn run_rootfs_flag_sets_rootfs_path_and_uses_trailing_command() { let cli = Cli::try_parse_from(["boxlite", "run", "--rootfs", "/tmp/rootfs", "echo", "hi"]) diff --git a/src/cli/src/commands/serve/handlers/config.rs b/src/cli/src/commands/serve/handlers/config.rs index a33d5cd2a..1a10f3fb2 100644 --- a/src/cli/src/commands/serve/handlers/config.rs +++ b/src/cli/src/commands/serve/handlers/config.rs @@ -7,6 +7,7 @@ use super::super::types::{ServerCapabilities, ServerConfig}; pub(in crate::commands::serve) async fn get_config() -> Json { Json(ServerConfig { capabilities: ServerCapabilities { + linux_capabilities_enabled: true, snapshots_enabled: true, clone_enabled: true, export_enabled: true, diff --git a/src/cli/src/commands/serve/mod.rs b/src/cli/src/commands/serve/mod.rs index 0a8962ab3..d18a042c5 100644 --- a/src/cli/src/commands/serve/mod.rs +++ b/src/cli/src/commands/serve/mod.rs @@ -766,6 +766,13 @@ fn build_box_options(req: &CreateBoxRequest) -> Result, + /// Expert-only container options. + #[serde(default)] + pub advanced: CreateBoxAdvancedOptions, #[serde(default)] pub network: Option, #[serde(default)] @@ -59,6 +62,19 @@ pub(super) struct CreateBoxRequest { // below for the wire-shape pin. } +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct CreateBoxAdvancedOptions { + pub capabilities: ContainerCapabilitiesRequest, +} + +#[derive(Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct ContainerCapabilitiesRequest { + pub add: Vec, + pub drop: Vec, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct NetworkSpec { @@ -161,6 +177,7 @@ pub(super) struct ServerConfig { #[derive(Serialize)] pub(super) struct ServerCapabilities { + pub linux_capabilities_enabled: bool, pub snapshots_enabled: bool, pub clone_enabled: bool, pub export_enabled: bool, diff --git a/src/guest/src/container/capabilities.rs b/src/guest/src/container/capabilities.rs index e3bb326d8..7c6c920cb 100644 --- a/src/guest/src/container/capabilities.rs +++ b/src/guest/src/container/capabilities.rs @@ -1,76 +1,261 @@ //! Linux capabilities for container processes. //! -//! Defines the default capability set matching Docker/OCI defaults. +//! Defines BoxLite's Docker-compatible baseline and custom capability policy. //! Used by: //! - OCI spec builder (process.capabilities) //! - Tenant process spawning (exec capabilities) -use oci_spec::runtime::Capability; +use boxlite_shared::errors::{BoxliteError, BoxliteResult}; +use oci_spec::runtime::{Capability, LinuxCapabilities, LinuxCapabilitiesBuilder}; +use serde::{Deserialize, Serialize}; use std::collections::HashSet; -/// Default capabilities for container processes. +// Linux UAPI capability numbers, in order from 0 through CAP_LAST_CAP. +const CAPABILITIES_BY_NUMBER: [Capability; 41] = [ + Capability::Chown, + Capability::DacOverride, + Capability::DacReadSearch, + Capability::Fowner, + Capability::Fsetid, + Capability::Kill, + Capability::Setgid, + Capability::Setuid, + Capability::Setpcap, + Capability::LinuxImmutable, + Capability::NetBindService, + Capability::NetBroadcast, + Capability::NetAdmin, + Capability::NetRaw, + Capability::IpcLock, + Capability::IpcOwner, + Capability::SysModule, + Capability::SysRawio, + Capability::SysChroot, + Capability::SysPtrace, + Capability::SysPacct, + Capability::SysAdmin, + Capability::SysBoot, + Capability::SysNice, + Capability::SysResource, + Capability::SysTime, + Capability::SysTtyConfig, + Capability::Mknod, + Capability::Lease, + Capability::AuditWrite, + Capability::AuditControl, + Capability::Setfcap, + Capability::MacOverride, + Capability::MacAdmin, + Capability::Syslog, + Capability::WakeAlarm, + Capability::BlockSuspend, + Capability::AuditRead, + Capability::Perfmon, + Capability::Bpf, + Capability::CheckpointRestore, +]; + +/// The resolved Linux capability policy for every process in one container. /// -/// Matches Docker's default capability set — sufficient for most workloads -/// while excluding dangerous capabilities like CAP_SYS_ADMIN (mount/remount, -/// namespace manipulation), CAP_NET_ADMIN (network reconfiguration), -/// CAP_SYS_MODULE (kernel module loading), and CAP_BPF. -pub fn default_capabilities() -> HashSet { - [ - Capability::Chown, - Capability::DacOverride, - Capability::Fowner, - Capability::Fsetid, - Capability::Kill, - Capability::Setgid, - Capability::Setuid, - Capability::Setpcap, - Capability::NetBindService, - Capability::NetRaw, - Capability::SysChroot, - Capability::Mknod, - Capability::AuditWrite, - Capability::Setfcap, - ] - .into_iter() - .collect() +/// This is the guest's single semantic boundary: external APIs carry familiar +/// add/drop strings, then this type parses and resolves them once before the +/// OCI spec or any exec process is built. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub(crate) struct CapabilitySet(HashSet); + +impl Default for CapabilitySet { + fn default() -> Self { + Self( + [ + Capability::Chown, + Capability::DacOverride, + Capability::Fowner, + Capability::Fsetid, + Capability::Kill, + Capability::Setgid, + Capability::Setuid, + Capability::Setpcap, + Capability::NetBindService, + Capability::NetRaw, + Capability::SysChroot, + Capability::Mknod, + Capability::AuditWrite, + Capability::Setfcap, + ] + .into_iter() + .collect(), + ) + } +} + +impl CapabilitySet { + /// Resolve Docker-style capability additions and removals. + /// + /// Names are case-insensitive and may include the `CAP_` prefix. `ALL` in + /// additions starts from every capability supported by this guest; `ALL` + /// in removals starts from an empty set. Without `ALL`, removals are applied + /// before additions, so an explicitly added capability wins a named + /// conflict. With `add=ALL`, named drops win, matching Moby exactly. + pub(crate) fn resolve(add: &[String], drop: &[String]) -> BoxliteResult { + let additions = parse_capabilities(add)?; + let removals = parse_capabilities(drop)?; + + let supported = if add.is_empty() && drop.is_empty() { + None + } else { + Some(supported_capabilities()?) + }; + if let Some(supported) = &supported { + ensure_supported(&additions, supported)?; + ensure_supported(&removals, supported)?; + } + + if add.iter().any(|name| is_all(name)) { + let mut resolved = supported.ok_or_else(|| { + BoxliteError::Internal( + "capability support was not resolved for add=ALL".to_string(), + ) + })?; + for capability in removals { + resolved.remove(&capability); + } + return Ok(Self(resolved)); + } + + if drop.iter().any(|name| is_all(name)) { + return Ok(Self(additions)); + } + + let mut resolved = Self::default().0; + for capability in removals { + resolved.remove(&capability); + } + resolved.extend(additions); + Ok(Self(resolved)) + } + + /// Build the exact OCI sets used by BoxLite's high-level add/drop policy. + /// + /// Docker/containerd apply ordinary container capabilities to bounding, + /// effective, and permitted. Inheritable and ambient are deliberately + /// absent: they require a separate security contract and implicitly + /// populating them has caused runtime vulnerabilities during exec. + pub(crate) fn to_oci(&self) -> BoxliteResult { + let mut capabilities = LinuxCapabilitiesBuilder::default() + .bounding(self.0.clone()) + .effective(self.0.clone()) + .permitted(self.0.clone()) + .build() + .map_err(|error| { + BoxliteError::Internal(format!("failed to build Linux capabilities: {error}")) + })?; + + // oci-spec 0.6's builder defaults these sets to three capabilities. + capabilities.set_inheritable(None); + capabilities.set_ambient(None); + Ok(capabilities) + } + + /// Canonical names accepted by libcontainer's tenant builder. + pub(crate) fn names(&self) -> Vec { + let mut names: Vec = self + .0 + .iter() + .map(|capability| format!("CAP_{capability}")) + .collect(); + names.sort_unstable(); + names + } + + #[cfg(test)] + fn len(&self) -> usize { + self.0.len() + } + + #[cfg(test)] + fn contains(&self, capability: &Capability) -> bool { + self.0.contains(capability) + } + + #[cfg(test)] + fn iter(&self) -> impl Iterator { + self.0.iter() + } +} + +fn parse_capabilities(names: &[String]) -> BoxliteResult> { + names + .iter() + .filter(|name| !is_all(name)) + .map(|name| parse_capability(name)) + .collect() +} + +fn parse_capability(name: &str) -> BoxliteResult { + let normalized = name.to_ascii_uppercase(); + let unprefixed = normalized.strip_prefix("CAP_").unwrap_or(&normalized); + unprefixed + .parse::() + .map_err(|_| BoxliteError::InvalidArgument(format!("unknown Linux capability '{name}'"))) +} + +fn is_all(name: &str) -> bool { + name.eq_ignore_ascii_case("ALL") || name.eq_ignore_ascii_case("CAP_ALL") } -/// Convert default capabilities to string names for libcontainer API. -pub fn capability_names() -> Vec { - [ - "CAP_CHOWN", - "CAP_DAC_OVERRIDE", - "CAP_FOWNER", - "CAP_FSETID", - "CAP_KILL", - "CAP_SETGID", - "CAP_SETUID", - "CAP_SETPCAP", - "CAP_NET_BIND_SERVICE", - "CAP_NET_RAW", - "CAP_SYS_CHROOT", - "CAP_MKNOD", - "CAP_AUDIT_WRITE", - "CAP_SETFCAP", - ] - .iter() - .map(|s| s.to_string()) - .collect() +fn supported_capabilities() -> BoxliteResult> { + let raw = std::fs::read_to_string("/proc/sys/kernel/cap_last_cap").map_err(|error| { + BoxliteError::Internal(format!( + "failed to read guest kernel capability ceiling: {error}" + )) + })?; + let last_capability = raw.trim().parse::().map_err(|error| { + BoxliteError::Internal(format!( + "invalid guest kernel capability ceiling '{}': {error}", + raw.trim() + )) + })?; + + Ok(CAPABILITIES_BY_NUMBER + .iter() + .take(last_capability.saturating_add(1)) + .copied() + .collect()) +} + +fn ensure_supported( + requested: &HashSet, + supported: &HashSet, +) -> BoxliteResult<()> { + if let Some(capability) = requested + .iter() + .find(|capability| !supported.contains(capability)) + { + return Err(BoxliteError::InvalidArgument(format!( + "Linux capability 'CAP_{capability}' is not supported by the guest kernel" + ))); + } + Ok(()) } #[cfg(test)] mod tests { use super::*; + fn names(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + #[test] fn default_capabilities_has_14_docker_defaults() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); assert_eq!(caps.len(), 14); } #[test] fn default_capabilities_includes_required_caps() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); let required = [ Capability::Chown, Capability::DacOverride, @@ -94,7 +279,7 @@ mod tests { #[test] fn default_capabilities_excludes_dangerous_caps() { - let caps = default_capabilities(); + let caps = CapabilitySet::default(); let dangerous = [ Capability::SysAdmin, Capability::NetAdmin, @@ -113,8 +298,8 @@ mod tests { #[test] fn capability_names_match_default_capabilities() { - let caps = default_capabilities(); - let names = capability_names(); + let caps = CapabilitySet::default(); + let names = caps.names(); assert_eq!(caps.len(), names.len()); for name in &names { assert!( @@ -127,8 +312,117 @@ mod tests { #[test] fn capability_names_are_all_uppercase() { - for name in capability_names() { + for name in CapabilitySet::default().names() { assert_eq!(name, name.to_uppercase(), "should be uppercase: {}", name); } } + + #[test] + fn kernel_capability_table_matches_linux_uapi_numbers() { + assert_eq!(CAPABILITIES_BY_NUMBER[0], Capability::Chown); + assert_eq!(CAPABILITIES_BY_NUMBER[31], Capability::Setfcap); + assert_eq!(CAPABILITIES_BY_NUMBER[37], Capability::AuditRead); + assert_eq!(CAPABILITIES_BY_NUMBER[40], Capability::CheckpointRestore); + } + + #[test] + fn resolve_capabilities_without_overrides_preserves_docker_defaults() { + let resolved = CapabilitySet::resolve(&[], &[]).expect("resolve default capabilities"); + + assert_eq!(resolved, CapabilitySet::default()); + assert_eq!(resolved.len(), 14); + } + + #[test] + fn resolve_capabilities_adds_docker_style_name() { + let resolved = + CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &[]).expect("resolve added capability"); + + assert_eq!(resolved.len(), 15); + assert!(resolved.contains(&Capability::SysAdmin)); + assert!(resolved.contains(&Capability::NetRaw)); + } + + #[test] + fn resolve_capabilities_drops_docker_style_name() { + let resolved = + CapabilitySet::resolve(&[], &names(&["NET_RAW"])).expect("resolve dropped capability"); + + assert_eq!(resolved.len(), 13); + assert!(!resolved.contains(&Capability::NetRaw)); + assert!(resolved.contains(&Capability::NetBindService)); + } + + #[test] + fn drop_all_then_add_keeps_only_explicit_additions() { + let resolved = CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &names(&["ALL"])) + .expect("resolve drop ALL with one addition"); + + assert_eq!( + resolved, + CapabilitySet([Capability::SysAdmin].into_iter().collect()) + ); + } + + #[test] + fn cap_prefixed_all_is_the_all_sentinel() { + assert!(is_all("CAP_ALL")); + assert!(is_all("cap_all")); + } + + #[test] + fn add_all_then_drop_removes_only_explicit_drops() { + let all = CapabilitySet::resolve(&names(&["ALL"]), &[]).expect("resolve ALL capabilities"); + let resolved = CapabilitySet::resolve(&names(&["ALL"]), &names(&["NET_RAW"])) + .expect("resolve ALL capabilities with one drop"); + + assert!(all.contains(&Capability::SysAdmin)); + assert!(all.contains(&Capability::NetRaw)); + assert_eq!(resolved.len(), all.len() - 1); + assert!(!resolved.contains(&Capability::NetRaw)); + assert!(all + .iter() + .filter(|capability| **capability != Capability::NetRaw) + .all(|capability| resolved.contains(capability))); + } + + #[test] + fn resolve_capabilities_rejects_unknown_name() { + let error = CapabilitySet::resolve(&names(&["CAP_NOT_REAL"]), &[]) + .expect_err("unknown capability must fail"); + + assert!( + error.to_string().contains("CAP_NOT_REAL"), + "error should identify the invalid capability: {error}" + ); + } + + #[test] + fn explicit_add_wins_when_capability_is_also_dropped() { + let resolved = CapabilitySet::resolve(&names(&["SYS_ADMIN"]), &names(&["CAP_SYS_ADMIN"])) + .expect("resolve conflicting capability overrides"); + + assert!(resolved.contains(&Capability::SysAdmin)); + } + + #[test] + fn capability_names_are_case_insensitive_prefixed_and_deduplicated() { + let resolved = CapabilitySet::resolve( + &names(&["sys_admin", "CAP_SYS_ADMIN", "cap_net_admin"]), + &[], + ) + .expect("resolve normalized capability names"); + + assert_eq!(resolved.len(), CapabilitySet::default().len() + 2); + assert!(resolved.contains(&Capability::SysAdmin)); + assert!(resolved.contains(&Capability::NetAdmin)); + } + + #[test] + fn add_all_ignores_explicit_additions_after_specific_drops() { + let resolved = CapabilitySet::resolve(&names(&["ALL", "MKNOD"]), &names(&["MKNOD"])) + .expect("resolve ALL with redundant explicit addition"); + + assert!(!resolved.contains(&Capability::Mknod)); + } } diff --git a/src/guest/src/container/command.rs b/src/guest/src/container/command.rs index e10a876d2..3f1d91a1f 100644 --- a/src/guest/src/container/command.rs +++ b/src/guest/src/container/command.rs @@ -3,6 +3,7 @@ //! Provides a builder pattern for spawning processes inside containers, //! following the `std::process::Command` pattern. +use super::capabilities::CapabilitySet; use super::zygote::{self, BuildSpec}; use crate::service::exec::exec_handle::{ExecHandle, PtyConfig}; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; @@ -57,6 +58,9 @@ pub struct ContainerCommand { /// Rootfs path for resolving user overrides from /etc/passwd. rootfs: Option, + /// Resolved capability set inherited by every exec process. + capabilities: CapabilitySet, + /// Working directory (None = use default "/") cwd: Option, @@ -78,6 +82,7 @@ impl ContainerCommand { env: HashMap, user: (u32, u32), rootfs: PathBuf, + capabilities: CapabilitySet, ) -> Self { Self { program: None, @@ -86,6 +91,7 @@ impl ContainerCommand { user, user_override: None, rootfs: Some(rootfs), + capabilities, cwd: None, console_socket: None, pty_config: None, @@ -386,6 +392,7 @@ impl ContainerCommand { args: container_args.clone(), uid, gid, + capabilities: self.capabilities.clone(), }; // Blocking IPC to zygote — use spawn_blocking to not block tokio. @@ -560,6 +567,7 @@ mod tests { HashMap::new(), (0, 0), PathBuf::from("/tmp/rootfs"), + CapabilitySet::default(), ) } diff --git a/src/guest/src/container/lifecycle.rs b/src/guest/src/container/lifecycle.rs index de6ebe8e4..a7b16cda2 100644 --- a/src/guest/src/container/lifecycle.rs +++ b/src/guest/src/container/lifecycle.rs @@ -3,6 +3,7 @@ //! Provides container creation, startup, and status checking using libcontainer. //! Follows the OCI Runtime Specification. +use super::capabilities::CapabilitySet; use super::command::ContainerCommand; use super::spec::UserMount; use super::stdio::{ContainerStdio, InitIo}; @@ -62,6 +63,8 @@ pub struct Container { env: HashMap, /// Resolved (uid, gid) from image USER directive, propagated to exec commands. user: (u32, u32), + /// Resolved capability set shared by init and every exec process. + capabilities: CapabilitySet, /// Stdio pipes that keep init process alive. /// Dropping this closes pipes → init gets EOF → init exits. #[allow(dead_code)] @@ -89,6 +92,7 @@ impl Container { /// - `env`: Environment variables in "KEY=VALUE" format /// - `workdir`: Working directory inside container /// - `user_mounts`: Bind mounts from guest VM paths into container + /// - `capabilities`: capability policy resolved at the RPC boundary /// /// # Errors /// @@ -106,6 +110,7 @@ impl Container { user: &str, user_mounts: Vec, tty: bool, + capabilities: CapabilitySet, ) -> BoxliteResult { let rootfs = rootfs.as_ref(); let workdir = workdir.as_ref(); @@ -178,6 +183,7 @@ impl Container { workdir, uid, gid, + &capabilities, &layout.containers_dir(), &user_mounts, tty, @@ -218,6 +224,7 @@ impl Container { bundle_path, env: env_map, user: (uid, gid), + capabilities, stdio, is_shutdown: std::sync::atomic::AtomicBool::new(false), }) @@ -366,6 +373,7 @@ impl Container { self.env.clone(), self.user, self.bundle_path.join("rootfs"), + self.capabilities.clone(), ) } diff --git a/src/guest/src/container/mod.rs b/src/guest/src/container/mod.rs index 9695fab96..8b33e00ba 100644 --- a/src/guest/src/container/mod.rs +++ b/src/guest/src/container/mod.rs @@ -76,6 +76,8 @@ mod stdio; #[cfg(target_os = "linux")] pub(crate) mod zygote; +#[cfg(target_os = "linux")] +pub(crate) use capabilities::CapabilitySet; #[cfg(target_os = "linux")] pub(crate) use command::SpawnResult; #[cfg(target_os = "linux")] diff --git a/src/guest/src/container/spec.rs b/src/guest/src/container/spec.rs index 6b096df0c..8f6c18115 100644 --- a/src/guest/src/container/spec.rs +++ b/src/guest/src/container/spec.rs @@ -2,14 +2,14 @@ //! //! Creates OCI-compliant runtime specifications following the runtime-spec standard. -use super::capabilities::default_capabilities; +use super::capabilities::CapabilitySet; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use std::path::Path; use oci_spec::runtime::{ - LinuxBuilder, LinuxCapabilitiesBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, - LinuxNamespaceType, Mount, MountBuilder, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder, - RootBuilder, Spec, SpecBuilder, UserBuilder, + LinuxBuilder, LinuxIdMappingBuilder, LinuxNamespaceBuilder, LinuxNamespaceType, Mount, + MountBuilder, PosixRlimitBuilder, PosixRlimitType, ProcessBuilder, RootBuilder, Spec, + SpecBuilder, UserBuilder, }; /// User-specified bind mount for container @@ -32,7 +32,7 @@ pub struct UserMount { /// Builds an OCI spec with: /// - Standard mounts (/proc, /dev, /sys, etc.) /// - User-specified bind mounts (volumes) -/// - Default capabilities (matching runc defaults) +/// - Resolved default and user-requested capabilities /// - Standard namespaces (pid, ipc, uts, mount) /// - UID/GID mappings for user namespace /// - Configurable user (resolved uid/gid) @@ -52,11 +52,12 @@ pub fn create_oci_spec( workdir: &str, uid: u32, gid: u32, + capabilities: &CapabilitySet, bundle_path: &Path, user_mounts: &[UserMount], tty: bool, ) -> BoxliteResult { - let caps = build_default_capabilities()?; + let caps = capabilities.to_oci()?; let namespaces = build_default_namespaces()?; let mut mounts = build_standard_mounts(bundle_path)?; @@ -265,20 +266,6 @@ fn find_group_in_group_file(rootfs: &str, name: &str) -> BoxliteResult { // Spec Component Builders // ==================== -/// Build default Linux capabilities matching Docker/OCI defaults. -fn build_default_capabilities() -> BoxliteResult { - let caps = default_capabilities(); - - LinuxCapabilitiesBuilder::default() - .bounding(caps.clone()) - .effective(caps.clone()) - .inheritable(caps.clone()) - .permitted(caps.clone()) - .ambient(caps) - .build() - .map_err(|e| BoxliteError::Internal(format!("Failed to build capabilities: {}", e))) -} - /// Build default namespaces for container isolation fn build_default_namespaces() -> BoxliteResult> { Ok(vec![ @@ -358,6 +345,7 @@ pub(crate) fn build_tty_exec_process( cwd: &str, uid: u32, gid: u32, + capabilities: CapabilitySet, ) -> BoxliteResult { let user = UserBuilder::default() .uid(uid) @@ -371,7 +359,7 @@ pub(crate) fn build_tty_exec_process( .args(args.to_vec()) .env(env) .cwd(cwd) - .capabilities(build_default_capabilities()?) + .capabilities(capabilities.to_oci()?) .no_new_privileges(false) .build() .map_err(|e| BoxliteError::Internal(format!("Failed to build tty exec process: {}", e))) @@ -586,6 +574,7 @@ fn build_standard_mounts(bundle_path: &Path) -> BoxliteResult> { #[cfg(test)] mod tests { use super::*; + use crate::container::capabilities::CapabilitySet; use std::fs; /// Create a temp rootfs with /etc/passwd and /etc/group for testing. @@ -628,6 +617,36 @@ mod tests { dir } + #[test] + fn tty_exec_process_uses_resolved_capabilities() { + let resolved = CapabilitySet::resolve(&["SYS_ADMIN".to_string()], &["NET_RAW".to_string()]) + .expect("resolve capabilities for tty exec"); + let expected = resolved + .to_oci() + .expect("build expected OCI capability sets"); + + let process = build_tty_exec_process( + &["sh".to_string()], + &["PATH=/bin".to_string()], + "/", + 0, + 0, + resolved.clone(), + ) + .expect("build tty exec process"); + + assert_eq!(process.terminal(), Some(true)); + let capabilities = process + .capabilities() + .as_ref() + .expect("tty exec process should have capabilities"); + assert_eq!(capabilities.bounding(), expected.bounding()); + assert_eq!(capabilities.effective(), expected.effective()); + assert_eq!(capabilities.permitted(), expected.permitted()); + assert_eq!(capabilities.inheritable(), &None); + assert_eq!(capabilities.ambient(), &None); + } + // ================== // Empty / root // ================== diff --git a/src/guest/src/container/start.rs b/src/guest/src/container/start.rs index f171e058b..468be6085 100644 --- a/src/guest/src/container/start.rs +++ b/src/guest/src/container/start.rs @@ -3,6 +3,7 @@ //! Provides setup, validation, and execution functions for starting containers. //! Separated from container.rs to group by lifecycle phase (Prepare → Execute). +use super::capabilities::CapabilitySet; use super::spec; use super::zygote; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; @@ -102,6 +103,7 @@ pub(crate) fn create_oci_bundle( workdir: &Path, uid: u32, gid: u32, + capabilities: &CapabilitySet, bundle_root: &Path, user_mounts: &[spec::UserMount], tty: bool, @@ -132,6 +134,7 @@ pub(crate) fn create_oci_bundle( .ok_or_else(|| BoxliteError::Internal("Invalid workdir path".to_string()))?, uid, gid, + capabilities, &bundle_path, user_mounts, tty, diff --git a/src/guest/src/container/zygote.rs b/src/guest/src/container/zygote.rs index d91eac540..89bd3901f 100644 --- a/src/guest/src/container/zygote.rs +++ b/src/guest/src/container/zygote.rs @@ -11,7 +11,7 @@ //! //! See `docs/investigations/concurrent-exec-deadlock.md` for full analysis. -use super::capabilities::capability_names; +use super::capabilities::CapabilitySet; use boxlite_shared::errors::{BoxliteError, BoxliteResult}; use libcontainer::container::builder::ContainerBuilder; use libcontainer::syscall::syscall::SyscallType; @@ -58,6 +58,7 @@ pub(crate) struct BuildSpec { pub args: Vec, pub uid: u32, pub gid: u32, + pub capabilities: CapabilitySet, } /// What init to build. Serialized over IPC to the zygote. @@ -265,9 +266,15 @@ fn do_build(spec: BuildSpec, fds: Option<[RawFd; 3]>) -> BuildResult { // The PTY path passes no stdio fds — youki wires the PTY slave instead. let env_vec: Vec = spec.env.iter().map(|(k, v)| format!("{k}={v}")).collect(); let cwd = spec.cwd.to_str().unwrap_or("/"); - let process = - super::spec::build_tty_exec_process(&spec.args, &env_vec, cwd, spec.uid, spec.gid) - .map_err(|e| format!("build tty exec process: {e}"))?; + let process = super::spec::build_tty_exec_process( + &spec.args, + &env_vec, + cwd, + spec.uid, + spec.gid, + spec.capabilities.clone(), + ) + .map_err(|e| format!("build tty exec process: {e}"))?; let process_json = serde_json::to_vec(&process) .map_err(|e| format!("serialize tty process.json: {e}"))?; let process_path = spec @@ -292,7 +299,7 @@ fn do_build(spec: BuildSpec, fds: Option<[RawFd; 3]>) -> BuildResult { // CLONE_PARENT so the tenant reparents to guest main (not the // zygote); guest main's reaper owns its exit. The zygote never waits. .as_sibling(true) - .with_capabilities(capability_names()) + .with_capabilities(spec.capabilities.names()) .with_no_new_privs(false) .with_detach(false) .with_cwd(Some(spec.cwd)) @@ -555,6 +562,7 @@ fn recv_response(sock: RawFd) -> BoxliteResult { #[cfg(test)] mod tests { use super::*; + use crate::container::capabilities::CapabilitySet; // ======================================================================== // Serialization tests (pure logic, no fork needed) @@ -578,6 +586,7 @@ mod tests { ], uid: 1000, gid: 1000, + capabilities: CapabilitySet::default(), } } @@ -633,6 +642,7 @@ mod tests { args: vec![], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; let json = serde_json::to_vec(&spec).unwrap(); let decoded: BuildSpec = serde_json::from_slice(&json).unwrap(); @@ -790,6 +800,7 @@ mod tests { args, uid: 65534, gid: 65534, + capabilities: CapabilitySet::default(), }; send_request(fd_a, &ZygoteRequest::Build(spec.clone()), None).unwrap(); @@ -819,6 +830,7 @@ mod tests { args: vec![], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; let (a, _b) = socketpair( @@ -968,6 +980,7 @@ mod tests { args: vec!["echo".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; z.build(spec, None).unwrap() })); @@ -1008,6 +1021,7 @@ mod tests { args: vec!["true".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), } } @@ -1350,6 +1364,7 @@ mod tests { args: vec!["true".to_string()], uid: 0, gid: 0, + capabilities: CapabilitySet::default(), }; do_build(spec, Some(fds)) }); diff --git a/src/guest/src/service/container.rs b/src/guest/src/service/container.rs index c8af7db7e..c83b4fa66 100644 --- a/src/guest/src/service/container.rs +++ b/src/guest/src/service/container.rs @@ -6,6 +6,7 @@ use std::path::Path; use crate::service::server::GuestServer; +use boxlite_shared::errors::BoxliteError; use boxlite_shared::{ container_init_response, container_start_response, rootfs_init, Container as ContainerService, ContainerInitError, ContainerInitRequest, ContainerInitResponse, ContainerInitSuccess, @@ -15,7 +16,7 @@ use nix::mount::{mount, MsFlags}; use tonic::{Request, Response, Status}; use tracing::{debug, error, info, warn}; -use crate::container::{Container, UserMount}; +use crate::container::{CapabilitySet, Container, UserMount}; use crate::layout::GuestLayout; use crate::storage::block_device::BlockDeviceMount; @@ -149,6 +150,17 @@ impl ContainerService for GuestServer { })); } + // Validate and resolve the privilege policy before creating directories, + // mounting the rootfs, or modifying its trust store. A malformed or + // unsupported name is caller input, not a partially initialized box. + let capability_policy = config + .advanced + .unwrap_or_default() + .capabilities + .unwrap_or_default(); + let capabilities = CapabilitySet::resolve(&capability_policy.add, &capability_policy.drop) + .map_err(BoxliteError::into_validation_status)?; + info!("🚀 Starting OCI container with received configuration"); // Compute rootfs paths from container_id @@ -295,6 +307,7 @@ impl ContainerService for GuestServer { &config.user, user_mounts, config.tty, + capabilities, ) { Ok(mut container) => { // Init is created, not yet running — Init never runs it; the diff --git a/src/shared/proto/boxlite/v1/service.proto b/src/shared/proto/boxlite/v1/service.proto index 4069a9483..e728089ed 100644 --- a/src/shared/proto/boxlite/v1/service.proto +++ b/src/shared/proto/boxlite/v1/service.proto @@ -307,6 +307,21 @@ message ContainerConfig { // guest opens the PTY at a default size and the attaching client's // ResizeTty sets the real one. bool tty = 5; + + // Expert-only container process options. + ContainerAdvancedOptions advanced = 6; +} + +message ContainerAdvancedOptions { + ContainerCapabilities capabilities = 1; +} + +message ContainerCapabilities { + // Capabilities to add to the default capability set. + repeated string add = 1; + + // Capabilities to remove from the resulting capability set. + repeated string drop = 2; } // ============================================================================ diff --git a/src/shared/src/errors.rs b/src/shared/src/errors.rs index 10eefaa67..f7d90be8b 100644 --- a/src/shared/src/errors.rs +++ b/src/shared/src/errors.rs @@ -152,6 +152,17 @@ impl From for BoxliteError { /// 500 because they indicate a server-side bug or data-plane /// corruption — not a recoverable condition. impl BoxliteError { + /// Convert an error returned by request validation into a gRPC status. + /// + /// Validators use [`BoxliteError::InvalidArgument`] for caller mistakes; + /// other variants indicate that validation itself could not be completed. + pub fn into_validation_status(self) -> tonic::Status { + match self { + BoxliteError::InvalidArgument(message) => tonic::Status::invalid_argument(message), + error => tonic::Status::internal(error.to_string()), + } + } + pub fn http(&self) -> (u16, &'static str, &'static str) { match self { BoxliteError::InvalidArgument(_) => (400, "InvalidArgumentError", "invalid_argument"), @@ -186,6 +197,22 @@ impl BoxliteError { mod tests { use super::*; + #[test] + fn validation_status_distinguishes_caller_input_from_internal_failure() { + let invalid = + BoxliteError::InvalidArgument("unknown capability".into()).into_validation_status(); + assert_eq!(invalid.code(), tonic::Code::InvalidArgument); + assert_eq!(invalid.message(), "unknown capability"); + + let internal = BoxliteError::Internal("cannot read capability ceiling".into()) + .into_validation_status(); + assert_eq!(internal.code(), tonic::Code::Internal); + assert_eq!( + internal.message(), + "internal error: cannot read capability ceiling" + ); + } + /// Canonical `BoxliteError → (status, error_type, code)` table. /// /// Each row is asserted via [`BoxliteError::http`]. Adding a