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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion docs/backends/bwrap/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,69 @@ Bubblewrap creates a namespace-isolated process by:
`$HOME`, `/root`, `/opt`, `/var`, `/sys`, and `/run/user/<uid>` β€” is
invisible inside the sandbox.
3. Layering filesystem policy overrides (read-write, read-only, denied paths)
4. Setting up minimal `/dev`, `/proc`, and `/tmp`
4. Setting up minimal `/dev` and `/tmp`; `/proc` is mounted only when the
selected procfs policy requests it
5. Clearing the environment and applying only requested variables
6. Executing the command via `sh -c`

The sandboxed process runs as a child of `bwrap` and dies automatically when
execution completes β€” no container lifecycle management required.

### Procfs policy and migration

Bubblewrap always creates a private PID namespace. Mounting a fresh procfs at
`/proc` exposes that namespace's process view, not the host PID namespace. With
procfs omitted, `/proc` is absent from Bubblewrap's empty-by-default root unless
the caller explicitly mounts a path there.

The typed V1 API exposes `ProcfsPolicy` through
`spawn_with_procfs_policy`, `run_with_procfs_policy`, and the PTY equivalent.
`ProcfsPolicy::default()` is `Omit`, the least-privilege choice. `Mount` opts in
to a fresh procfs; `LegacyBackendDefault` preserves the historical behavior.
The existing `FilesystemPolicy` shape and the published JSON contract are
unchanged.

The internal control variable used to carry the typed choice is rejected in
external `process.env` input and removed before the sandboxed child environment
is assembled. Procfs selection is therefore not an undocumented JSON option.

Tentative compatibility schedule:

1. **V1.0.x:** Existing `spawn`, `run`, PTY, and versioned JSON entrypoints
preserve their historical backend behavior. New callers can select the
secure default with `ProcfsPolicy::default()`; callers requiring historical
behavior can name `LegacyBackendDefault` explicitly.
2. **V1.1:** Mark the legacy typed entrypoints as deprecated, with migration
guidance to the procfs-policy entrypoints. Do not change runtime behavior in
this minor release. Versioned JSON requests remain unchanged.
3. **V2.0:** Make the unqualified typed entrypoints default to `Omit`. Keep
`LegacyBackendDefault` available for compatibility-sensitive workloads, and
preserve the behavior of existing V1 JSON schemas. A future wire-schema
version may add an explicit `mountProcfs` field; older schemas must retain
their original semantics.

The release timing is tentative and should be confirmed against downstream
usage data. Do not emit warnings for explicit `Mount` or explicit
`LegacyBackendDefault` selections.

### Fuzzing the procfs request boundary

The `procfs_policy` cargo-fuzz target exercises the external MXC request parser
and asserts that an accepted request cannot retain the reserved internal
`MXC_INTERNAL_MOUNT_PROCFS` environment variable. It does not start a sandboxed
process. Run a bounded campaign from the MXC repository root:

```bash
cargo +nightly fuzz run procfs_policy --fuzz-dir src/testing/fuzz -- \
-max_total_time=30 -max_len=4096
```

The 2026-10-15 Linux run completed 2,276,062 executions in 31 seconds without a
crash. Curated JSON inputs under
`src/testing/fuzz/corpus/procfs_policy/` cover ordinary environment values and
the reserved variable with omit, mount, and unknown values; generated corpus
entries and artifacts remain local.

### Deny-by-default filesystem

The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture:
Expand Down
67 changes: 66 additions & 1 deletion src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,23 @@ pub(crate) fn build_args_classified_with_mode(
// Standard virtual filesystems (applied before policy mounts so policy
// paths under /dev, /proc, or /tmp survive).
args.extend(["--dev".into(), "/dev".into()]);
args.extend(["--proc".into(), "/proc".into()]);

let procfs_policy = request.env.as_ref().and_then(|environment| {
environment.iter().find_map(|entry| {
entry
.strip_prefix(crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV)
.and_then(|value| value.strip_prefix('='))
})
});
let mount_procfs = match procfs_policy {
Some("off") => false,
Some("on") | None => true,
Some(_) => false,
};
if mount_procfs {
args.extend(["--proc".into(), "/proc".into()]);
}

args.extend(["--tmpfs".into(), "/tmp".into()]);

// Policy mounts, emitted in most-specific-path-wins order so a deeper path
Expand Down Expand Up @@ -511,6 +527,9 @@ pub(crate) fn build_args_classified_with_mode(
for env_str in resolved_env(request) {
// An entry with no `=` names no variable, so it is dropped here too.
if let Some((key, value)) = env_str.split_once('=') {
if key == crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV {
continue;
}
// When the proxy is active, drop any caller-supplied proxy env
// entries so they cannot override the values we set below.
if proxy_address.is_some() && is_managed_proxy_key(key) {
Expand Down Expand Up @@ -956,6 +975,52 @@ mod tests {
assert!(args.contains(&"--unshare-uts".to_string()));
}

#[test]
fn explicit_procfs_omit_keeps_pid_namespace_and_hides_internal_marker() {
let mut request = base_request();
request.env = Some(vec![format!(
"{}=off",
crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV
)]);
request.inherit_default_env = true;

let args = build_args(&request, None);

let separator = args.iter().position(|argument| argument == "--").unwrap();
assert!(args[..separator]
.iter()
.any(|argument| argument == "--unshare-pid"));
assert!(!args[..separator]
.iter()
.any(|argument| argument == "--proc"));
assert!(!args.windows(3).any(|window| window[0] == "--setenv"
&& window[1] == crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV));
assert!(args
.windows(3)
.any(|window| window == ["--setenv", "PATH", DEFAULT_PATH]));
}

#[test]
fn explicit_procfs_mount_and_legacy_default_keep_procfs_available() {
let mut request = base_request();
request.env = Some(vec![format!(
"{}=on",
crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV
)]);
request.inherit_default_env = true;
let explicit_mount = build_args(&request, None);
assert!(explicit_mount
.windows(2)
.any(|window| window == ["--proc", "/proc"]));

request.env = None;
request.inherit_default_env = false;
let legacy_default = build_args(&request, None);
assert!(legacy_default
.windows(2)
.any(|window| window == ["--proc", "/proc"]));
}

/// Dropping this flag silently reintroduces a real leak: a backgrounded
/// descendant survives the timeout kill and keeps running after teardown
/// has removed its network enforcement.
Expand Down
29 changes: 29 additions & 0 deletions src/mxc-sdk/src/core/mxc_common/config_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,21 @@ fn normalize_common_request_ir(
None => (String::new(), String::new(), 0, None, false),
};

if env.as_ref().is_some_and(|entries| {
entries.iter().any(|entry| {
entry
.split_once('=')
.is_some_and(|(key, _)| key == crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV)
})
}) {
let message = format!(
"process.env must not set reserved variable {}",
crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV
);
logger.log_line(&message);
return Err(WxcError::ConfigParse(message));
}

// Containment backend selection. The wire enum has already constrained the
// value to a known variant (invalid strings fail at deserialize); abstract
// intents and the omitted case resolve to the OS-native backend here.
Expand Down Expand Up @@ -4361,6 +4376,20 @@ mod tests {
assert_eq!(request.policy.enumerate_paths, vec!["C:\\tools"]);
}

#[test]
fn external_process_environment_cannot_set_internal_procfs_control() {
for value in ["off", "on"] {
let json = format!(
r#"{{"version":"1.0.0","process":{{"commandLine":"echo hi","env":["{}={value}"]}}}}"#,
crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV
);
let error = load_mxc_request_from_json(&json, &mut test_logger()).unwrap_err();
assert!(error
.message()
.contains("process.env must not set reserved variable"));
}
}

#[test]
fn exact_v0_9_readwrite_and_enumerate_prefers_enumerate() {
let json = r#"{"version":"0.9.0-alpha","process":{"commandLine":"echo hi"},"containment":"processcontainer","filesystem":{"readwritePaths":["C:\\tools"]},"processContainer":{"filesystem":{"enumeratePaths":["C:\\tools"]}}}"#;
Expand Down
4 changes: 4 additions & 0 deletions src/mxc-sdk/src/core/mxc_common/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ use std::net::IpAddr;
use crate::mxc_common::error::WxcError;
use crate::mxc_common::mxc_error::MxcErrorCode;

/// Internal typed-SDK control consumed by the Linux Bubblewrap builder and
/// never passed through to the sandboxed child environment.
pub(crate) const INTERNAL_MOUNT_PROCFS_ENV: &str = "MXC_INTERNAL_MOUNT_PROCFS";

/// Selects which containment backend to use for script execution.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
Expand Down
60 changes: 58 additions & 2 deletions src/mxc-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ pub mod v1 {
#[cfg(target_os = "windows")]
pub use crate::mxc_engine::{ProbeFacts, ProbeOutput, UiCapabilitySupport};
pub use crate::options::{
DeprovisionOptions, ProvisionOptions, RunInContainerOptions, RunOptions,
DeprovisionOptions, ProcfsPolicy, ProvisionOptions, RunInContainerOptions, RunOptions,
SpawnInContainerOptions, SpawnInContainerWithPtyOptions, SpawnOptions, SpawnWithPtyOptions,
StartOptions, StopOptions, TelemetryConfig,
};
Expand Down Expand Up @@ -328,12 +328,37 @@ pub mod v1 {
/// Returns a [`MxcProcess`] handle for live bidirectional stdio and termination;
/// no pty is allocated. Any stdout/stderr stream the caller does not
/// `take_*` is drained and discarded by [`wait`](MxcProcess::wait).
/// This entrypoint preserves the historical backend procfs default. New
/// callers should use [`spawn_with_procfs_policy`] with
/// [`ProcfsPolicy::default()`].
pub fn spawn(request: ContainerRequest, options: SpawnOptions) -> Result<MxcProcess, Error> {
let prepared = crate::policy::prepare_creation_request(&request, options.telemetry)?;
crate::mxc_engine::spawn_execution_request(&prepared.inner).map(MxcProcess::new)
}

/// Spawn a request with an explicit Linux Bubblewrap procfs policy.
///
/// `ProcfsPolicy::default()` omits procfs while retaining the private PID
/// namespace. Existing [`spawn`] calls retain their historical backend
/// behavior; choose `LegacyBackendDefault` here to request that behavior
/// explicitly.
pub fn spawn_with_procfs_policy(
request: ContainerRequest,
procfs_policy: ProcfsPolicy,
options: SpawnOptions,
) -> Result<MxcProcess, Error> {
let prepared = crate::policy::prepare_creation_request_with_procfs_policy(
&request,
options.telemetry,
procfs_policy,
)?;
crate::mxc_engine::spawn_execution_request(&prepared.inner).map(MxcProcess::new)
}

/// Spawn a [`ContainerRequest`] attached to a caller-controlled PTY.
/// This entrypoint preserves the historical backend procfs default; new
/// callers can choose explicitly with
/// [`spawn_with_pty_and_procfs_policy`].
pub fn spawn_with_pty(
request: ContainerRequest,
options: SpawnWithPtyOptions,
Expand All @@ -344,6 +369,23 @@ pub mod v1 {
.and_then(MxcPtyProcess::new)
}

/// Spawn a request on a caller-controlled PTY with an explicit Linux
/// Bubblewrap procfs policy.
pub fn spawn_with_pty_and_procfs_policy(
request: ContainerRequest,
procfs_policy: ProcfsPolicy,
options: SpawnWithPtyOptions,
) -> Result<MxcPtyProcess, Error> {
options.size.validate()?;
let prepared = crate::policy::prepare_creation_request_with_procfs_policy(
&request,
options.telemetry,
procfs_policy,
)?;
crate::mxc_engine::spawn_with_pty(&prepared.inner, options.size.into())
.and_then(MxcPtyProcess::new)
}

/// Run a [`ContainerRequest`] to completion and capture its output.
///
/// Both streams are drained concurrently, so an output-heavy child cannot
Expand All @@ -354,9 +396,23 @@ pub mod v1 {
///
/// `Err` is returned when the backend can't be selected/spawned (an
/// [`Error`]), or when waiting on the child fails at the OS level.
/// This entrypoint preserves the historical backend procfs default; new
/// callers should use [`run_with_procfs_policy`] with
/// [`ProcfsPolicy::default()`].
pub fn run(request: ContainerRequest, options: RunOptions) -> Result<ExecutionResult, Error> {
crate::wait_with_output(spawn(
run_with_procfs_policy(request, ProcfsPolicy::LegacyBackendDefault, options)
}

/// Run a request to completion with an explicit Linux Bubblewrap procfs
/// policy. `ProcfsPolicy::default()` omits procfs.
pub fn run_with_procfs_policy(
request: ContainerRequest,
procfs_policy: ProcfsPolicy,
options: RunOptions,
) -> Result<ExecutionResult, Error> {
crate::wait_with_output(spawn_with_procfs_policy(
request,
procfs_policy,
SpawnOptions {
telemetry: options.telemetry,
},
Expand Down
13 changes: 13 additions & 0 deletions src/mxc-sdk/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ pub struct TelemetryConfig {
pub enabled: Option<bool>,
}

/// Procfs mount policy for Linux Bubblewrap execution.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProcfsPolicy {
/// Do not mount procfs. Bubblewrap still uses a private PID namespace.
#[default]
Omit,
/// Mount a fresh procfs inside the sandbox's private PID namespace.
Mount,
/// Preserve the historical backend default for compatibility.
LegacyBackendDefault,
}

/// Invocation controls for captured container execution.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RunOptions {
Expand Down
Loading