Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6df9d50
feat(box): add privileged plumbing for DinD
G4614 Aug 4, 2026
15164f9
fix(rest): satisfy Rust 1.97 clippy
G4614 Aug 4, 2026
495ee56
feat(cli): add privileged flag to run and create
G4614 Aug 4, 2026
c1023d5
feat(box): make privileged mode a regular option
G4614 Aug 4, 2026
fb9bcf9
chore(api): refresh generated clients
G4614 Aug 4, 2026
bdb2c1a
chore(api): complete generated client refresh
G4614 Aug 4, 2026
8dc30fe
chore(api): preserve generated file ending
G4614 Aug 4, 2026
5373a6d
fix(box): normalize privileged options at every boundary
G4614 Aug 4, 2026
9239c74
fix(guest): retain shared readonly path policy
G4614 Aug 4, 2026
19eb831
fix(api): defer capability support validation to guest
G4614 Aug 4, 2026
65f06d5
fix(box): reject conflicting privileged capabilities
G4614 Aug 4, 2026
c4e67c9
style(python): format privileged options test
G4614 Aug 4, 2026
91a6915
fix(box): make privileged mode guest-complete
G4614 Aug 4, 2026
b7d49b8
refactor(box): resolve privileged policy before guest boundary
G4614 Aug 4, 2026
6055c38
docs(box): describe the complete guest-level privileged shape
G4614 Aug 5, 2026
55998ef
fix(box): correct privileged normalization, /sys scope and SDK toggle
G4614 Aug 5, 2026
b6bd1de
fix(box): gate privileged on the release that introduces it
G4614 Aug 5, 2026
881e7db
refactor: move control-plane/runner/SDK changes to a separate PR
G4614 Aug 6, 2026
0f6aade
Delete docs/architecture/pr-646-dind-capabilities.md
G4614 Aug 6, 2026
58f1584
fix(container): resolve privileged policy on host
G4614 Aug 6, 2026
21d9cb5
fix(runtime): sanitize REST options centrally
G4614 Aug 6, 2026
8222001
test(guest): compile atomic policy test
G4614 Aug 6, 2026
31f5e95
test(guest): cover independent atomic policy
G4614 Aug 6, 2026
4e98fe4
test(guest): drop guest-side recheck now that host is trusted
G4614 Aug 6, 2026
9b0b01e
docs(proto): explain container security fields
G4614 Aug 6, 2026
6ae5abe
fix(container): drop cgroup namespace and allow-all device rule
G4614 Aug 7, 2026
d7ea2da
docs: revert reference docs to main pending a follow-up pass
G4614 Aug 11, 2026
e0dbea9
fix(runtime): drop create_inner's redundant privileged normalization
G4614 Aug 11, 2026
88abf6b
refactor(privileged): host resolves masked/readonly paths, guest appl…
G4614 Aug 12, 2026
6f93caa
refactor(scope): move CLI/self-hosted-REST privileged exposure to a f…
G4614 Aug 12, 2026
1eb8b2a
refactor(privileged): source default masked/readonly paths from oci-spec
G4614 Aug 12, 2026
ba99cef
refactor(privileged): stop resolving masked_paths through privileged
G4614 Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/boxlite/src/experimental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,13 @@ mod tests {
let enabled = ExperimentalFeatures::parse("nested-virtualization").unwrap();
enabled.require_for_options(&options).unwrap();
}

#[test]
fn privileged_shape_does_not_require_an_experimental_token() {
let mut options = BoxOptions::default();
options.advanced.privileged = true;
ExperimentalFeatures::default()
.require_for_options(&options)
.expect("privileged mode is a regular box option");
}
}
42 changes: 39 additions & 3 deletions src/boxlite/src/litebox/init/tasks/guest_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ use boxlite_shared::errors::{BoxliteError, BoxliteResult};
/// one long after its own release.
const MIN_CAPABILITY_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion = (0, 9, 8);

/// Oldest guest release that applies the privileged DinD spec shape. The
/// privileged field ships in the same release as the capability policy above,
/// so it carries the same floor: a guest new enough to honor one understands
/// the other. Requiring a later release would reject the very guest that
/// introduces the feature.
const MIN_PRIVILEGED_CONTAINER_GUEST_VERSION: crate::portal::interfaces::guest::GuestVersion =
(0, 9, 8);

/// Oldest guest release that honors `devices` on `Container.Init`. Same trap as
/// capabilities above: an earlier guest drops the field and starts the workload
/// with no `/dev/kvm` while the caller believes nesting was granted.
Expand Down Expand Up @@ -71,6 +79,7 @@ impl PipelineTask<InitCtx> for GuestInitTask {
}),
crate::runtime::options::NetworkSpec::Disabled => None,
};
let advanced = ctx.config.options.advanced.resolve_container_security()?;
let bootstrap = GuestBootstrapConfig {
guest: GuestInitConfig {
volumes: volume_mgr.build_guest_mounts(),
Expand All @@ -88,9 +97,7 @@ impl PipelineTask<InitCtx> for GuestInitTask {
} else {
Vec::new()
},
advanced: crate::portal::interfaces::container::ContainerAdvancedConfig {
capabilities: ctx.config.options.advanced.capabilities.clone(),
},
advanced: advanced.into(),
},
};

Expand Down Expand Up @@ -134,6 +141,17 @@ async fn run_guest_init(
.require_min_version(MIN_CAPABILITY_GUEST_VERSION)
.await?;
}
// An older guest's Container.Init doesn't understand
// readonly_paths/sys_mount_options at all and would silently keep its own
// hardened defaults, so privileged mode needs a version gate the same way
// capability overrides do. (No masked_paths here: it never varies with
// `privileged` — see advanced_options.rs — so it carries no version
// requirement of its own.)
if bootstrap.container.advanced.readonly_paths.is_empty() {
guest_interface
.require_min_version(MIN_PRIVILEGED_CONTAINER_GUEST_VERSION)
.await?;
}
if !bootstrap.container.devices.is_empty() {
guest_interface
.require_min_version(MIN_DEVICE_GUEST_VERSION)
Expand Down Expand Up @@ -165,3 +183,21 @@ fn kvm_device() -> ContainerDevice {
file_mode: Some(0o666),
}
}

#[cfg(test)]
mod tests {
use super::*;
/// A privileged request always carries the capability policy it expands to,
/// so any guest that satisfies the privileged floor must already satisfy
/// the capability floor. Pinning the constants to literals only restates
/// them; this is the relation that catches a privileged floor set to a
/// release later than the one introducing the feature, which would reject
/// that very guest.
#[test]
fn privileged_gate_is_never_newer_than_the_capability_gate() {
assert!(
MIN_PRIVILEGED_CONTAINER_GUEST_VERSION <= MIN_CAPABILITY_GUEST_VERSION,
"privileged floor {MIN_PRIVILEGED_CONTAINER_GUEST_VERSION:?} is newer than the capability floor {MIN_CAPABILITY_GUEST_VERSION:?}"
);
}
}
38 changes: 34 additions & 4 deletions src/boxlite/src/portal/interfaces/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use boxlite_shared::{
use tonic::transport::Channel;

use crate::images::ContainerImageConfig;
use crate::runtime::advanced_options::ContainerCapabilities;
use crate::runtime::advanced_options::{ContainerCapabilities, ResolvedContainerSecurityConfig};
use crate::volumes::ContainerMount;

/// Container rootfs initialization strategy.
Expand Down Expand Up @@ -90,9 +90,21 @@ pub struct ContainerInitConfig {
pub advanced: ContainerAdvancedConfig,
Comment thread
G4614 marked this conversation as resolved.
}

/// Expert-only options crossing the host-to-guest container boundary.
#[derive(Debug, Clone, Default)]
pub struct ContainerAdvancedConfig {
pub capabilities: ContainerCapabilities,
pub(crate) readonly_paths: Vec<String>,
pub(crate) sys_mount_options: Vec<String>,
}

impl From<ResolvedContainerSecurityConfig> for ContainerAdvancedConfig {
fn from(value: ResolvedContainerSecurityConfig) -> Self {
Self {
capabilities: value.capabilities,
readonly_paths: value.readonly_paths,
sys_mount_options: value.sys_mount_options,
}
}
}

/// Container service interface.
Expand Down Expand Up @@ -137,6 +149,8 @@ impl ContainerInterface {
add: advanced.capabilities.add,
drop: advanced.capabilities.drop,
}),
readonly_paths: advanced.readonly_paths,
sys_mount_options: advanced.sys_mount_options,
}),
};

Expand Down Expand Up @@ -444,7 +458,17 @@ mod tests {
file_mode: Some(0o666),
}],
advanced: ContainerAdvancedConfig {
capabilities: Default::default(),
capabilities: crate::runtime::advanced_options::ContainerCapabilities {
add: vec!["ALL".into()],
..Default::default()
},
readonly_paths: Vec::new(),
sys_mount_options: vec![
"rbind".to_string(),
"nosuid".to_string(),
"noexec".to_string(),
"nodev".to_string(),
],
},
})
.await
Expand All @@ -456,7 +480,13 @@ mod tests {
assert_eq!(request.devices[0].file_mode, Some(0o666));
assert_eq!(request.container_id, "container-1");
assert_eq!(request.execution_id, "container-1");
let container_config = request.container_config.expect("process config");
// Non-default, so it proves the field is threaded rather than defaulted.
assert!(request.container_config.expect("process config").tty);
assert!(container_config.tty);
let advanced = container_config.advanced.expect("advanced options");
// Non-default resolved values, so they prove the fields are threaded
// verbatim rather than defaulted or recomputed on the way to the wire.
assert!(advanced.readonly_paths.is_empty());
assert!(!advanced.sys_mount_options.contains(&"rro".to_string()));
}
}
207 changes: 207 additions & 0 deletions src/boxlite/src/runtime/advanced_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,12 @@ impl ContainerCapabilities {
self.add.is_empty() && self.drop.is_empty()
}

pub(crate) fn is_privileged_capability_shape(&self) -> bool {
self.drop.is_empty()
&& self.add.len() == 1
&& canonical_capability_name(&self.add[0]) == "ALL"
}

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)
Expand Down Expand Up @@ -717,4 +723,205 @@ pub struct AdvancedBoxOptions {
#[doc(hidden)]
#[serde(default)]
pub nested_virtualization: bool,

/// Docker-style privileged OCI spec shape for DinD.
#[serde(default)]
pub privileged: bool,
}

impl AdvancedBoxOptions {
/// Reject capability overrides that conflict with privileged mode.
///
/// The canonical `add=["ALL"]` shape is allowed for persisted and FFI
/// options that have already been normalized. Other explicit overrides
/// must not be silently discarded by privileged mode.
pub(crate) fn validate_privileged_capability_conflict(
&self,
) -> boxlite_shared::errors::BoxliteResult<()> {
if self.privileged
&& !self.capabilities.is_empty()
&& !self.capabilities.is_privileged_capability_shape()
{
return Err(boxlite_shared::errors::BoxliteError::InvalidArgument(
"privileged mode cannot be combined with cap_add or cap_drop".to_string(),
));
}

Ok(())
}

/// Toggle privileged mode, keeping the capability policy consistent in
/// both directions.
///
/// Enabling expands to the canonical shape; disabling withdraws it again,
/// so a handle that is toggled off does not leave a non-privileged box
/// holding `ALL`. An explicit policy the caller set themselves is left
/// alone — only the shape this method produced is taken back.
pub fn set_privileged(&mut self, enabled: bool) {
self.privileged = enabled;

if enabled {
self.normalize_privileged();
} else if self.capabilities.is_privileged_capability_shape() {
self.capabilities = ContainerCapabilities::default();
}
}

/// Expand the high-level privileged mode into the explicit capability
/// policy consumed by the guest. Call
/// `validate_privileged_capability_conflict` before this method; a
/// conflicting explicit override is deliberately left untouched so it
/// cannot be silently discarded.
pub(crate) fn normalize_privileged(&mut self) {
if self.privileged
&& (self.capabilities.is_empty() || self.capabilities.is_privileged_capability_shape())
{
self.capabilities.add = vec!["ALL".to_string()];
self.capabilities.drop.clear();
}
}

/// Resolve the container security request before it crosses into the guest.
///
/// The host owns the public option semantics *and* the literal OCI values
/// that follow from it — the readonly-path list and the `/sys` bind's
/// mount options are resolved here, not re-derived by the guest from a
/// flag (see docs/architecture/privileged-mode-design.md, Trade-offs,
/// option F). Masked paths are deliberately not part of this: nothing in
/// DinD reads a masked path, so the guest keeps applying its own oci-spec
/// default unconditionally, the same way it did before `privileged`
/// existed — see the Trade-offs note on the finding that motivated
/// dropping it. The guest still resolves the canonical capability names
/// against its own kernel ceiling, but it must not reinterpret
/// `privileged` or silently discard capability overrides.
pub(crate) fn resolve_container_security(
&self,
) -> boxlite_shared::errors::BoxliteResult<ResolvedContainerSecurityConfig> {
self.validate_privileged_capability_conflict()?;

let mut normalized = self.clone();
normalized.normalize_privileged();

let readonly_paths = if normalized.privileged {
Vec::new()
} else {
default_readonly_paths()
};

Ok(ResolvedContainerSecurityConfig {
capabilities: normalized.capabilities,
readonly_paths,
sys_mount_options: sys_mount_options(normalized.privileged),
})
}
}

/// Default OCI readonly-path list for a non-privileged container. Sourced
/// from `oci_spec::runtime::get_default_readonly_paths()` for the same
/// no-drift reason `advanced_options.rs`'s other host-resolved values follow.
fn default_readonly_paths() -> Vec<String> {
oci_spec::runtime::get_default_readonly_paths()
}

/// Full resolved option list for the guest's `/sys` recursive bind mount.
/// Recursive: `/sys` is an rbind, and OCI's plain `ro` is applied without
/// `AT_RECURSIVE`, which would leave the guest's cgroup2 submount writable
/// inside the container — hence `rro`, not `ro`, for the non-privileged case.
fn sys_mount_options(privileged: bool) -> Vec<String> {
let mut options: Vec<String> = ["rbind", "nosuid", "noexec", "nodev"]
.into_iter()
.map(String::from)
.collect();
if !privileged {
options.push("rro".to_string());
}
options
}

/// Atomic container security configuration crossing the host-to-guest
/// boundary. `readonly_paths`/`sys_mount_options` are literal, host-resolved
/// OCI values the guest assigns verbatim — matching how Docker, Podman, and
/// Kata Containers all hand the enforcing side a finished shape rather than a
/// flag to reinterpret (see docs/architecture/privileged-mode-design.md,
/// Trade-offs, option F). `capabilities` is the one exception: only the
/// guest, across the VM boundary, knows its own kernel's capability ceiling,
/// so it stays as add/drop deltas the guest resolves itself.
///
/// No masked-path field, no cgroup namespace, no allow-all device-cgroup
/// rule: all three were tested and found unnecessary for DinD — nothing in
/// the DinD workflow reads a masked path, the guest never enforced a
/// restrictive device-cgroup default in the first place, and `dockerd`
/// tolerated running without a private cgroup namespace view. The guest keeps
/// applying its own oci-spec masked-path default unconditionally.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ResolvedContainerSecurityConfig {
pub(crate) capabilities: ContainerCapabilities,
pub(crate) readonly_paths: Vec<String>,
pub(crate) sys_mount_options: Vec<String>,
}

#[cfg(test)]
mod resolved_security_tests {
use super::*;

/// `default_readonly_paths` calls straight into
/// `oci_spec::runtime::get_default_readonly_paths()` — a caret
/// dependency, so a semver-compatible release could change what that
/// returns without BoxLite choosing to. Pinned to the exact list so that
/// happening is a decision to review, not a silent security-posture
/// change picked up on the next `cargo update`.
#[test]
fn unprivileged_resolves_hardened_path_defaults() {
let resolved = AdvancedBoxOptions::default()
.resolve_container_security()
.expect("default (unprivileged) security should resolve");

assert_eq!(
resolved.readonly_paths,
[
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
]
.map(String::from)
);
assert!(resolved.sys_mount_options.contains(&"rro".to_string()));
}

#[test]
fn privileged_resolves_cleared_readonly_paths_and_writable_sys() {
let mut options = AdvancedBoxOptions::default();
options.set_privileged(true);

let resolved = options
.resolve_container_security()
.expect("privileged security should resolve");

assert!(resolved.readonly_paths.is_empty());
assert!(!resolved.sys_mount_options.contains(&"rro".to_string()));
assert_eq!(resolved.capabilities.add, ["ALL"]);
}

/// Capabilities and the OCI path/mount shape are resolved from the same
/// `privileged` bool but are otherwise independent knobs: a capability
/// override alone (no `privileged`) must not relax the hardened paths.
#[test]
fn capability_override_without_privileged_keeps_hardened_paths() {
let options = AdvancedBoxOptions {
capabilities: ContainerCapabilities {
add: vec!["SYS_ADMIN".to_string()],
..Default::default()
},
..Default::default()
};

let resolved = options
.resolve_container_security()
.expect("capability-only options should resolve");

assert!(!resolved.readonly_paths.is_empty());
assert!(resolved.sys_mount_options.contains(&"rro".to_string()));
}
}
Loading
Loading