From 4b147b0c9491b1d7247824d5677ee4d75c7ca8a1 Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Thu, 15 Oct 2026 08:26:42 +0000 Subject: [PATCH 1/9] feat(mxc-sdk): add explicit Bubblewrap procfs policy Add additive SDK entrypoints for omitting or explicitly mounting procfs without changing the historical behavior of existing spawn and run APIs. Keep the private PID namespace in no-proc mode and remove the proc mount only from Bubblewrap options before the command separator. Reserve the internal procfs control variable so external process environments cannot select or spoof the SDK policy, and strip the marker before the child environment is constructed. Document the compatibility contract and cover policy mapping, legacy behavior, explicit mount/omit, and malformed Bubblewrap command handling. --- docs/backends/bwrap/bubblewrap-backend.md | 40 ++++++- .../bubblewrap/common/bwrap_command.rs | 67 ++++++++++- .../src/core/mxc_common/config_parser.rs | 29 +++++ src/mxc-sdk/src/core/mxc_common/models.rs | 4 + src/mxc-sdk/src/lib.rs | 60 +++++++++- src/mxc-sdk/src/options.rs | 13 +++ src/mxc-sdk/src/policy.rs | 110 +++++++++++++++++- 7 files changed, 318 insertions(+), 5 deletions(-) diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index df11c43a8..889ef86e4 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -140,13 +140,51 @@ Bubblewrap creates a namespace-isolated process by: `$HOME`, `/root`, `/opt`, `/var`, `/sys`, and `/run/user/` — 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. + ### Deny-by-default filesystem The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture: diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index 111a6393c..1b1dff3e0 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -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 @@ -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) { @@ -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. diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index be2969871..76b357896 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -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. @@ -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"]}}}"#; diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index 12f3be0f0..d10822c38 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -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")] diff --git a/src/mxc-sdk/src/lib.rs b/src/mxc-sdk/src/lib.rs index cef215ddf..cf5506a80 100644 --- a/src/mxc-sdk/src/lib.rs +++ b/src/mxc-sdk/src/lib.rs @@ -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, }; @@ -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 { 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 { + 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, @@ -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 { + 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 @@ -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 { - 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 { + crate::wait_with_output(spawn_with_procfs_policy( request, + procfs_policy, SpawnOptions { telemetry: options.telemetry, }, diff --git a/src/mxc-sdk/src/options.rs b/src/mxc-sdk/src/options.rs index 3c91427ee..a6671054d 100644 --- a/src/mxc-sdk/src/options.rs +++ b/src/mxc-sdk/src/options.rs @@ -8,6 +8,19 @@ pub struct TelemetryConfig { pub enabled: Option, } +/// 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 { diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index 86b23be93..67d6db8df 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -26,6 +26,7 @@ use crate::configs::{ProcessContainerConfig, WslcConfig}; #[cfg(test)] use crate::mxc_common::logger::{Logger, Mode}; use crate::mxc_common::models::{ExecutionRequest, TelemetryConfig}; +use crate::options::ProcfsPolicy; pub use network::{ NetworkAction, NetworkEgressPolicy, NetworkIngressPolicy, NetworkPeerPolicy, NetworkPolicy, NetworkPortPolicy, NetworkProtocol, NetworkRulePolicy, NetworkRuntimeConfig, @@ -849,8 +850,16 @@ pub(crate) fn build_request_with_containment( exact::build_request(policy, containment, script, container_name) } +#[cfg(any(test, target_os = "windows"))] pub(crate) fn prepare_request( request: &ContainerRequest, +) -> Result { + prepare_request_with_procfs_policy(request, ProcfsPolicy::LegacyBackendDefault) +} + +pub(crate) fn prepare_request_with_procfs_policy( + request: &ContainerRequest, + procfs_policy: ProcfsPolicy, ) -> Result { let policy = ContainerPolicy { filesystem: request.filesystem.clone(), @@ -868,10 +877,36 @@ pub(crate) fn prepare_request( prepared.inner.env = request.environment.as_ref().map(|environment| { environment .iter() + .filter(|(key, _)| key != crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV) .map(|(key, value)| format!("{key}={value}")) .collect() }); prepared.inner.inherit_default_env = request.inherit_default_environment.unwrap_or(false); + let procfs_control = match procfs_policy { + ProcfsPolicy::Omit => Some("off"), + ProcfsPolicy::Mount => Some("on"), + ProcfsPolicy::LegacyBackendDefault => None, + }; + if cfg!(target_os = "linux") + && matches!( + request.containment, + Containment::Process | Containment::Bubblewrap + ) + { + if let Some(procfs_control) = procfs_control { + if request.environment.is_none() { + prepared.inner.inherit_default_env = true; + } + prepared + .inner + .env + .get_or_insert_with(Vec::new) + .push(format!( + "{}={procfs_control}", + crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV + )); + } + } Ok(prepared) } @@ -879,7 +914,19 @@ pub(crate) fn prepare_creation_request( request: &ContainerRequest, telemetry: Option, ) -> Result { - let mut prepared = prepare_request(request)?; + prepare_creation_request_with_procfs_policy( + request, + telemetry, + ProcfsPolicy::LegacyBackendDefault, + ) +} + +pub(crate) fn prepare_creation_request_with_procfs_policy( + request: &ContainerRequest, + telemetry: Option, + procfs_policy: ProcfsPolicy, +) -> Result { + let mut prepared = prepare_request_with_procfs_policy(request, procfs_policy)?; prepared.inner.experimental_enabled = false; if let Some(telemetry) = telemetry { prepared.inner.telemetry = Some(TelemetryConfig { @@ -1264,6 +1311,67 @@ mod tests { assert_eq!(request.inner.container_id, "test-container".to_string()); } + #[cfg(target_os = "linux")] + #[test] + fn procfs_policy_controls_bubblewrap_without_changing_legacy_requests() { + let mut request = super::ContainerRequest::new("/usr/bin/true"); + request.containment = super::Containment::Bubblewrap; + + let prepared = super::prepare_request_with_procfs_policy( + &request, + crate::options::ProcfsPolicy::default(), + ) + .unwrap(); + let environment = prepared.inner.env.as_ref().unwrap(); + + assert!(prepared.inner.inherit_default_env); + assert!(environment.iter().any(|entry| { + entry + == &format!( + "{}=off", + crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV + ) + })); + + let legacy = super::prepare_request(&request).unwrap(); + assert!(!legacy + .inner + .env + .unwrap_or_default() + .iter() + .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=off")); + + let explicit_mount = super::prepare_request_with_procfs_policy( + &request, + crate::options::ProcfsPolicy::Mount, + ) + .unwrap(); + assert!(explicit_mount + .inner + .env + .unwrap() + .iter() + .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=on")); + + let mut spoofed = super::ContainerRequest::new("/usr/bin/true"); + spoofed.containment = super::Containment::Bubblewrap; + spoofed.environment = Some(vec![( + crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV.into(), + "off".into(), + )]); + let prepared = super::prepare_request_with_procfs_policy( + &spoofed, + crate::options::ProcfsPolicy::LegacyBackendDefault, + ) + .unwrap(); + assert!(!prepared + .inner + .env + .unwrap() + .iter() + .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=off")); + } + #[test] fn build_request_maps_enumerate_paths_for_v1() { let policy = ContainerPolicy { From 520cd807d088e3afa87d44485e4ee4ddb950d44b Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Thu, 15 Oct 2026 09:23:14 +0000 Subject: [PATCH 2/9] test(mxc): fuzz the procfs request boundary Add a cargo-fuzz target for external MXC JSON requests that asserts successful parsing never retains the reserved internal procfs environment marker. Seed ordinary requests and marker values for omission, mounting, and invalid input. The target parses only and never launches a sandbox process. A bounded Linux campaign completed 2,276,062 executions in 31 seconds without a crash. --- docs/backends/bwrap/bubblewrap-backend.md | 18 +++++++++++ src/testing/fuzz/.gitignore | 2 ++ src/testing/fuzz/Cargo.toml | 7 +++++ .../fuzz/corpus/procfs_policy/mount.json | 1 + .../fuzz/corpus/procfs_policy/omit.json | 1 + .../corpus/procfs_policy/unknown-value.json | 1 + .../fuzz/corpus/procfs_policy/valid.json | 1 + .../fuzz/fuzz_targets/procfs_policy.rs | 31 +++++++++++++++++++ 8 files changed, 62 insertions(+) create mode 100644 src/testing/fuzz/corpus/procfs_policy/mount.json create mode 100644 src/testing/fuzz/corpus/procfs_policy/omit.json create mode 100644 src/testing/fuzz/corpus/procfs_policy/unknown-value.json create mode 100644 src/testing/fuzz/corpus/procfs_policy/valid.json create mode 100644 src/testing/fuzz/fuzz_targets/procfs_policy.rs diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 889ef86e4..50bedb4e9 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -185,6 +185,24 @@ 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: diff --git a/src/testing/fuzz/.gitignore b/src/testing/fuzz/.gitignore index 522b4bd49..013065789 100644 --- a/src/testing/fuzz/.gitignore +++ b/src/testing/fuzz/.gitignore @@ -2,3 +2,5 @@ target/ Cargo.lock artifacts/ coverage/ +/corpus/procfs_policy/* +!/corpus/procfs_policy/*.json diff --git a/src/testing/fuzz/Cargo.toml b/src/testing/fuzz/Cargo.toml index 1ac43ee9c..f6cca7177 100644 --- a/src/testing/fuzz/Cargo.toml +++ b/src/testing/fuzz/Cargo.toml @@ -31,6 +31,13 @@ test = false doc = false bench = false +[[bin]] +name = "procfs_policy" +path = "fuzz_targets/procfs_policy.rs" +test = false +doc = false +bench = false + [[bin]] name = "base64_decode" path = "fuzz_targets/base64_decode.rs" diff --git a/src/testing/fuzz/corpus/procfs_policy/mount.json b/src/testing/fuzz/corpus/procfs_policy/mount.json new file mode 100644 index 000000000..f2adfb7c0 --- /dev/null +++ b/src/testing/fuzz/corpus/procfs_policy/mount.json @@ -0,0 +1 @@ +{"version":"1.0.0","process":{"commandLine":"true","env":["MXC_INTERNAL_MOUNT_PROCFS=on"]}} \ No newline at end of file diff --git a/src/testing/fuzz/corpus/procfs_policy/omit.json b/src/testing/fuzz/corpus/procfs_policy/omit.json new file mode 100644 index 000000000..1ec47d44f --- /dev/null +++ b/src/testing/fuzz/corpus/procfs_policy/omit.json @@ -0,0 +1 @@ +{"version":"1.0.0","process":{"commandLine":"true","env":["MXC_INTERNAL_MOUNT_PROCFS=off"]}} \ No newline at end of file diff --git a/src/testing/fuzz/corpus/procfs_policy/unknown-value.json b/src/testing/fuzz/corpus/procfs_policy/unknown-value.json new file mode 100644 index 000000000..6769dcef1 --- /dev/null +++ b/src/testing/fuzz/corpus/procfs_policy/unknown-value.json @@ -0,0 +1 @@ +{"version":"1.0.0","process":{"commandLine":"true","env":["MXC_INTERNAL_MOUNT_PROCFS=unexpected"]}} \ No newline at end of file diff --git a/src/testing/fuzz/corpus/procfs_policy/valid.json b/src/testing/fuzz/corpus/procfs_policy/valid.json new file mode 100644 index 000000000..efe159895 --- /dev/null +++ b/src/testing/fuzz/corpus/procfs_policy/valid.json @@ -0,0 +1 @@ +{"version":"1.0.0","process":{"commandLine":"true","env":["PATH=/usr/bin"]}} \ No newline at end of file diff --git a/src/testing/fuzz/fuzz_targets/procfs_policy.rs b/src/testing/fuzz/fuzz_targets/procfs_policy.rs new file mode 100644 index 000000000..4d22120db --- /dev/null +++ b/src/testing/fuzz/fuzz_targets/procfs_policy.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +// +// Fuzz the external request boundary for the reserved Bubblewrap procfs +// control. External JSON must never be able to select the SDK's internal +// procfs policy through process.env. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use mxc_sdk::mxc_common::config_parser::load_mxc_request; +use mxc_sdk::mxc_common::logger::{Logger, Mode}; +use mxc_sdk::mxc_common::state_aware_request::MxcRequest; + +const RESERVED_PROCFS_ENV: &str = "MXC_INTERNAL_MOUNT_PROCFS"; + +fuzz_target!(|data: &[u8]| { + let Ok(json) = std::str::from_utf8(data) else { + return; + }; + let mut logger = Logger::new(Mode::Buffer); + if let Ok(MxcRequest::OneShot(request)) = load_mxc_request(json, &mut logger, false) { + if let Some(environment) = request.env { + assert!(!environment.iter().any(|entry| { + entry + .split_once('=') + .is_some_and(|(key, _)| key == RESERVED_PROCFS_ENV) + })); + } + } +}); From 0170d456c1fae470e4e83d0d02856d9ee4d73b5d Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Fri, 23 Oct 2026 23:55:17 +0000 Subject: [PATCH 3/9] fix(policy): add procfs environment filtering in request preparation: remove trailing = --- src/mxc-sdk/src/policy.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index 67d6db8df..d4baca0ee 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -877,7 +877,11 @@ pub(crate) fn prepare_request_with_procfs_policy( prepared.inner.env = request.environment.as_ref().map(|environment| { environment .iter() - .filter(|(key, _)| key != crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV) + .filter(|(key, _)| { + key.split_once('=') + .map_or(key.as_str(), |(name, _)| name) + != crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV + }) .map(|(key, value)| format!("{key}={value}")) .collect() }); From 4a7af4e96f56dca86f5afe346e7c9ec8d5dd9fcf Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Fri, 23 Oct 2026 23:58:04 +0000 Subject: [PATCH 4/9] test(fuzz): parse procfs policy as JSON Co-authored-by: Copilot --- src/testing/fuzz/fuzz_targets/procfs_policy.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/testing/fuzz/fuzz_targets/procfs_policy.rs b/src/testing/fuzz/fuzz_targets/procfs_policy.rs index 4d22120db..99a303e89 100644 --- a/src/testing/fuzz/fuzz_targets/procfs_policy.rs +++ b/src/testing/fuzz/fuzz_targets/procfs_policy.rs @@ -8,7 +8,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use mxc_sdk::mxc_common::config_parser::load_mxc_request; +use mxc_sdk::mxc_common::config_parser::load_mxc_request_from_json; use mxc_sdk::mxc_common::logger::{Logger, Mode}; use mxc_sdk::mxc_common::state_aware_request::MxcRequest; @@ -19,7 +19,7 @@ fuzz_target!(|data: &[u8]| { return; }; let mut logger = Logger::new(Mode::Buffer); - if let Ok(MxcRequest::OneShot(request)) = load_mxc_request(json, &mut logger, false) { + if let Ok(MxcRequest::OneShot(request)) = load_mxc_request_from_json(json, &mut logger) { if let Some(environment) = request.env { assert!(!environment.iter().any(|entry| { entry From 0a7430752d23d24217d4bb79a64fbe81ce170c57 Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Sat, 24 Oct 2026 00:04:43 +0000 Subject: [PATCH 5/9] fix(policy): include Bubblewrap procfs choice in policy identity Distinguish `Mount` and `Omit` requests in policy hashes while keeping ordinary environment values excluded. Co-authored-by: Copilot --- src/mxc-sdk/src/policy.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index d4baca0ee..976bb6917 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -897,6 +897,19 @@ pub(crate) fn prepare_request_with_procfs_policy( Containment::Process | Containment::Bubblewrap ) { + if matches!(procfs_policy, ProcfsPolicy::Omit) + && !prepared + .inner + .policy + .denied_paths + .iter() + .any(|path| path == "/proc") + { + // Ordinary environment values are excluded from policy identity. + // Project procfs omission as a filesystem restriction too, so + // requests with different procfs exposure have different hashes. + prepared.inner.policy.denied_paths.push("/proc".to_string()); + } if let Some(procfs_control) = procfs_control { if request.environment.is_none() { prepared.inner.inherit_default_env = true; @@ -1329,6 +1342,12 @@ mod tests { let environment = prepared.inner.env.as_ref().unwrap(); assert!(prepared.inner.inherit_default_env); + assert!(prepared + .inner + .policy + .denied_paths + .iter() + .any(|path| path == "/proc")); assert!(environment.iter().any(|entry| { entry == &format!( @@ -1350,6 +1369,12 @@ mod tests { crate::options::ProcfsPolicy::Mount, ) .unwrap(); + assert!(!explicit_mount + .inner + .policy + .denied_paths + .iter() + .any(|path| path == "/proc")); assert!(explicit_mount .inner .env From 6adb5660f9e848fdb9e1432d63273dc300717956 Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Sat, 24 Oct 2026 00:24:13 +0000 Subject: [PATCH 6/9] feat(policy): add no_procfs option to control procfs exposure in requests Co-authored-by: Copilot --- src/mxc-sdk/src/policy.rs | 41 ++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index 976bb6917..14e7759ee 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -897,19 +897,6 @@ pub(crate) fn prepare_request_with_procfs_policy( Containment::Process | Containment::Bubblewrap ) { - if matches!(procfs_policy, ProcfsPolicy::Omit) - && !prepared - .inner - .policy - .denied_paths - .iter() - .any(|path| path == "/proc") - { - // Ordinary environment values are excluded from policy identity. - // Project procfs omission as a filesystem restriction too, so - // requests with different procfs exposure have different hashes. - prepared.inner.policy.denied_paths.push("/proc".to_string()); - } if let Some(procfs_control) = procfs_control { if request.environment.is_none() { prepared.inner.inherit_default_env = true; @@ -1333,6 +1320,10 @@ mod tests { fn procfs_policy_controls_bubblewrap_without_changing_legacy_requests() { let mut request = super::ContainerRequest::new("/usr/bin/true"); request.containment = super::Containment::Bubblewrap; + request.filesystem = Some(super::FilesystemPolicy { + readonly_paths: vec!["/proc".to_string()], + ..Default::default() + }); let prepared = super::prepare_request_with_procfs_policy( &request, @@ -1342,12 +1333,18 @@ mod tests { let environment = prepared.inner.env.as_ref().unwrap(); assert!(prepared.inner.inherit_default_env); - assert!(prepared + assert!(!prepared .inner .policy .denied_paths .iter() .any(|path| path == "/proc")); + assert!(prepared + .inner + .policy + .readonly_paths + .iter() + .any(|path| path == "/proc")); assert!(environment.iter().any(|entry| { entry == &format!( @@ -1382,6 +1379,22 @@ mod tests { .iter() .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=on")); + // Omission is an independent procfs selection, not a filesystem + // denial: with no explicit grant, `/proc` remains absent from both + // filesystem lists instead of becoming an empty tmpfs mount. + let mut no_procfs_grant = super::ContainerRequest::new("/usr/bin/true"); + no_procfs_grant.containment = super::Containment::Bubblewrap; + let omitted = super::prepare_request_with_procfs_policy( + &no_procfs_grant, + crate::options::ProcfsPolicy::Omit, + ) + .unwrap(); + assert!(!omitted.inner.policy.denied_paths.iter().any(|path| path == "/proc")); + assert!(!omitted.inner.policy.readonly_paths.iter().any(|path| path == "/proc")); + assert!(omitted.inner.env.as_ref().unwrap().iter().any(|entry| { + entry == "MXC_INTERNAL_MOUNT_PROCFS=off" + })); + let mut spoofed = super::ContainerRequest::new("/usr/bin/true"); spoofed.containment = super::Containment::Bubblewrap; spoofed.environment = Some(vec![( From 35a2efb3f5d9d333ce08c683fd84d7ecd1f7a89f Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Sat, 24 Oct 2026 00:35:25 +0000 Subject: [PATCH 7/9] fix(policy): enhance procfs policy handling for Linux containment types Co-authored-by: Copilot --- src/mxc-sdk/src/policy.rs | 82 ++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index 14e7759ee..44f17d63b 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -861,6 +861,24 @@ pub(crate) fn prepare_request_with_procfs_policy( request: &ContainerRequest, procfs_policy: ProcfsPolicy, ) -> Result { + let procfs_control = match procfs_policy { + ProcfsPolicy::Omit => Some("off"), + ProcfsPolicy::Mount => Some("on"), + ProcfsPolicy::LegacyBackendDefault => None, + }; + if procfs_control.is_some() + && !(cfg!(target_os = "linux") + && matches!( + request.containment, + Containment::Process | Containment::Bubblewrap + )) + { + return Err(crate::mxc_common::mxc_error::MxcError::policy_validation( + "procfs policy is supported only on Linux Process or Bubblewrap containment", + ) + .into()); + } + let policy = ContainerPolicy { filesystem: request.filesystem.clone(), network: request.network.clone(), @@ -886,30 +904,18 @@ pub(crate) fn prepare_request_with_procfs_policy( .collect() }); prepared.inner.inherit_default_env = request.inherit_default_environment.unwrap_or(false); - let procfs_control = match procfs_policy { - ProcfsPolicy::Omit => Some("off"), - ProcfsPolicy::Mount => Some("on"), - ProcfsPolicy::LegacyBackendDefault => None, - }; - if cfg!(target_os = "linux") - && matches!( - request.containment, - Containment::Process | Containment::Bubblewrap - ) - { - if let Some(procfs_control) = procfs_control { - if request.environment.is_none() { - prepared.inner.inherit_default_env = true; - } - prepared - .inner - .env - .get_or_insert_with(Vec::new) - .push(format!( - "{}={procfs_control}", - crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV - )); + if let Some(procfs_control) = procfs_control { + if request.environment.is_none() { + prepared.inner.inherit_default_env = true; } + prepared + .inner + .env + .get_or_insert_with(Vec::new) + .push(format!( + "{}={procfs_control}", + crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV + )); } Ok(prepared) } @@ -1414,6 +1420,36 @@ mod tests { .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=off")); } + #[cfg(target_os = "linux")] + #[test] + fn explicit_procfs_policy_rejects_lxc_but_legacy_default_proceeds() { + let mut request = super::ContainerRequest::new("/usr/bin/true"); + request.containment = super::Containment::Lxc(crate::configs::LxcConfig::default()); + + for procfs_policy in [ + crate::options::ProcfsPolicy::Omit, + crate::options::ProcfsPolicy::Mount, + ] { + let error = super::prepare_request_with_procfs_policy(&request, procfs_policy) + .err() + .expect("explicit procfs policy must be rejected for LXC"); + assert_eq!(error.code, crate::mxc_engine::ErrorCode::PolicyValidation); + assert!(error.message.contains("Linux Process or Bubblewrap")); + } + + let legacy = super::prepare_request_with_procfs_policy( + &request, + crate::options::ProcfsPolicy::LegacyBackendDefault, + ) + .expect("legacy procfs behavior remains available for LXC"); + assert!(!legacy + .inner + .env + .unwrap_or_default() + .iter() + .any(|entry| entry.starts_with("MXC_INTERNAL_MOUNT_PROCFS="))); + } + #[test] fn build_request_maps_enumerate_paths_for_v1() { let policy = ContainerPolicy { From 38dc171a387837d6fccd4946b2d9322308826c13 Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Sat, 24 Oct 2026 08:22:21 +0000 Subject: [PATCH 8/9] fix(policy): Bubblewrap procfs policy handling Updated `policy_identity.rs` so the identity regression exercises typed `Omit`, `Mount`, and legacy selections through request preparation. It verifies that `Omit` hashes differently, while explicit `Mount` matches the legacy default. All 7 procfs-focused tests pass, and `git diff --check` is clean. The GitHub review thread remains open pending the updated change being pushed and acknowledged. --- .../bubblewrap/common/bwrap_command.rs | 67 ++++++++++++++---- .../src/core/mxc_common/config_parser.rs | 1 + src/mxc-sdk/src/core/mxc_common/models.rs | 8 ++- .../src/core/mxc_common/policy_identity.rs | 28 ++++++++ src/mxc-sdk/src/policy.rs | 68 +++++++------------ 5 files changed, 112 insertions(+), 60 deletions(-) diff --git a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs index 1b1dff3e0..3bf796a0f 100644 --- a/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs +++ b/src/mxc-sdk/src/backends/bubblewrap/common/bwrap_command.rs @@ -467,19 +467,7 @@ pub(crate) fn build_args_classified_with_mode( // paths under /dev, /proc, or /tmp survive). args.extend(["--dev".into(), "/dev".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 { + if request.mount_procfs.unwrap_or(true) { args.extend(["--proc".into(), "/proc".into()]); } @@ -978,8 +966,9 @@ mod tests { #[test] fn explicit_procfs_omit_keeps_pid_namespace_and_hides_internal_marker() { let mut request = base_request(); + request.mount_procfs = Some(false); request.env = Some(vec![format!( - "{}=off", + "{}=on", crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV )]); request.inherit_default_env = true; @@ -1003,8 +992,9 @@ mod tests { #[test] fn explicit_procfs_mount_and_legacy_default_keep_procfs_available() { let mut request = base_request(); + request.mount_procfs = Some(true); request.env = Some(vec![format!( - "{}=on", + "{}=off", crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV )]); request.inherit_default_env = true; @@ -1015,12 +1005,59 @@ mod tests { request.env = None; request.inherit_default_env = false; + request.mount_procfs = None; let legacy_default = build_args(&request, None); assert!(legacy_default .windows(2) .any(|window| window == ["--proc", "/proc"])); } + #[cfg(target_os = "linux")] + #[test] + fn prepared_procfs_omit_stays_separate_from_filesystem_mounts() { + let mut request = crate::policy::ContainerRequest::new("/usr/bin/true"); + request.containment = crate::policy::Containment::Bubblewrap; + request.filesystem = Some(crate::policy::FilesystemPolicy { + readonly_paths: vec!["/proc".into()], + ..Default::default() + }); + + let prepared = crate::policy::prepare_request_with_procfs_policy( + &request, + crate::options::ProcfsPolicy::Omit, + ) + .expect("typed Bubblewrap request should prepare"); + assert_eq!(prepared.inner.mount_procfs, Some(false)); + assert!(prepared.inner.policy.denied_paths.is_empty()); + assert!(prepared + .inner + .policy + .readonly_paths + .iter() + .any(|path| path == "/proc")); + + let args = build_args(&prepared.inner, None); + assert!(!args.windows(2).any(|window| window == ["--proc", "/proc"])); + assert!(args + .windows(3) + .any(|window| window == ["--ro-bind", "/proc", "/proc"])); + assert!(!args.windows(2).any(|window| window == ["--tmpfs", "/proc"])); + + request.filesystem = None; + let omitted = crate::policy::prepare_request_with_procfs_policy( + &request, + crate::options::ProcfsPolicy::Omit, + ) + .expect("typed Bubblewrap request without a grant should prepare"); + let args = build_args(&omitted.inner, None); + assert!(!args + .windows(2) + .any(|window| window[1] == "/proc" && window[0] == "--proc")); + assert!(!args + .windows(2) + .any(|window| window[1] == "/proc" && window[0] == "--tmpfs")); + } + /// 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. diff --git a/src/mxc-sdk/src/core/mxc_common/config_parser.rs b/src/mxc-sdk/src/core/mxc_common/config_parser.rs index 76b357896..c54c88563 100644 --- a/src/mxc-sdk/src/core/mxc_common/config_parser.rs +++ b/src/mxc-sdk/src/core/mxc_common/config_parser.rs @@ -1626,6 +1626,7 @@ fn normalize_common_request_ir( working_directory, script_timeout, containment, + mount_procfs: None, lifecycle, policy, lxc_config, diff --git a/src/mxc-sdk/src/core/mxc_common/models.rs b/src/mxc-sdk/src/core/mxc_common/models.rs index d10822c38..a2f54cc25 100644 --- a/src/mxc-sdk/src/core/mxc_common/models.rs +++ b/src/mxc-sdk/src/core/mxc_common/models.rs @@ -7,8 +7,8 @@ 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. +/// Reserved environment name rejected for caller-supplied values. Procfs +/// selection travels separately on [`ExecutionRequest::mount_procfs`]. pub(crate) const INTERNAL_MOUNT_PROCFS_ENV: &str = "MXC_INTERNAL_MOUNT_PROCFS"; /// Selects which containment backend to use for script execution. @@ -993,6 +993,10 @@ pub struct ExecutionRequest { pub script_timeout: u32, /// Which containment backend to use. Default: ProcessContainer. pub containment: ContainmentBackend, + /// Whether Bubblewrap should mount procfs. `None` preserves its backend default. + /// This is an internal typed-SDK setting and is not part of the wire contract. + #[serde(skip)] + pub mount_procfs: Option, /// Shared lifecycle settings. pub lifecycle: LifecycleConfig, /// ProcessContainer-specific policy (used when containment == ProcessContainer). diff --git a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs index 8d3d1dbd6..c07ede3f0 100644 --- a/src/mxc-sdk/src/core/mxc_common/policy_identity.rs +++ b/src/mxc-sdk/src/core/mxc_common/policy_identity.rs @@ -37,6 +37,7 @@ //! |---|---| //! | `script_code` | The command line is *what runs*, not the policy under which it runs; it also routinely embeds credentials (`curl -H "Authorization: …"`). | //! | `env` | Environment variables are the classic secret carrier. | +//! | `mount_procfs` | Effective Bubblewrap procfs behavior is projected separately as `mountProcfs`, without hashing unrelated environment variables. | //! | `telemetry`, internal `test_feature` | No enforcement effect. | //! | `network_proxy.original_url` | A proxy URL can embed `user:password@`. The host and port *are* hashed. | //! | `capture_denials.output_path` | Only decides where the diagnostic JSON deliverable is written; not enforcement. `capture_denials.mode` remains hashed. | @@ -211,6 +212,7 @@ fn policy_projection(request: &ExecutionRequest) -> Value { working_directory, script_timeout, containment, + mount_procfs, lifecycle, policy, lxc_config, @@ -275,6 +277,12 @@ fn policy_projection(request: &ExecutionRequest) -> Value { } root.insert("policy".into(), policy_value); root.insert("proxy".into(), proxy_projection(request)); + if *containment == crate::mxc_common::models::ContainmentBackend::Bubblewrap { + root.insert( + "mountProcfs".into(), + Value::Bool(mount_procfs.unwrap_or(true)), + ); + } root.insert( "lxc".into(), serde_json::to_value(lxc_config).unwrap_or(Value::Null), @@ -613,6 +621,26 @@ mod tests { assert_ne!(baseline, policy_hash(&changed)); } + #[test] + fn effective_bubblewrap_procfs_selection_changes_the_hash() { + let mut legacy_default = request(); + legacy_default.containment = ContainmentBackend::Bubblewrap; + let legacy_hash = policy_hash(&legacy_default); + + let mut explicit_mount = legacy_default.clone(); + explicit_mount.mount_procfs = Some(true); + assert_eq!(legacy_hash, policy_hash(&explicit_mount)); + + let mut omit = legacy_default; + omit.mount_procfs = Some(false); + assert_ne!(legacy_hash, policy_hash(&omit)); + + let Value::Object(projection) = policy_projection(&omit) else { + panic!("policy projection must be an object"); + }; + assert_eq!(projection.get("mountProcfs"), Some(&Value::Bool(false))); + } + #[test] fn changing_the_proxy_port_changes_the_hash() { let baseline = policy_hash(&request()); diff --git a/src/mxc-sdk/src/policy.rs b/src/mxc-sdk/src/policy.rs index 44f17d63b..47a1f19ff 100644 --- a/src/mxc-sdk/src/policy.rs +++ b/src/mxc-sdk/src/policy.rs @@ -861,12 +861,12 @@ pub(crate) fn prepare_request_with_procfs_policy( request: &ContainerRequest, procfs_policy: ProcfsPolicy, ) -> Result { - let procfs_control = match procfs_policy { - ProcfsPolicy::Omit => Some("off"), - ProcfsPolicy::Mount => Some("on"), + let mount_procfs = match procfs_policy { + ProcfsPolicy::Omit => Some(false), + ProcfsPolicy::Mount => Some(true), ProcfsPolicy::LegacyBackendDefault => None, }; - if procfs_control.is_some() + if mount_procfs.is_some() && !(cfg!(target_os = "linux") && matches!( request.containment, @@ -904,19 +904,7 @@ pub(crate) fn prepare_request_with_procfs_policy( .collect() }); prepared.inner.inherit_default_env = request.inherit_default_environment.unwrap_or(false); - if let Some(procfs_control) = procfs_control { - if request.environment.is_none() { - prepared.inner.inherit_default_env = true; - } - prepared - .inner - .env - .get_or_insert_with(Vec::new) - .push(format!( - "{}={procfs_control}", - crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV - )); - } + prepared.inner.mount_procfs = mount_procfs; Ok(prepared) } @@ -1336,9 +1324,9 @@ mod tests { crate::options::ProcfsPolicy::default(), ) .unwrap(); - let environment = prepared.inner.env.as_ref().unwrap(); - assert!(prepared.inner.inherit_default_env); + assert!(!prepared.inner.inherit_default_env); + assert_eq!(prepared.inner.mount_procfs, Some(false)); assert!(!prepared .inner .policy @@ -1351,39 +1339,24 @@ mod tests { .readonly_paths .iter() .any(|path| path == "/proc")); - assert!(environment.iter().any(|entry| { - entry - == &format!( - "{}=off", - crate::mxc_common::models::INTERNAL_MOUNT_PROCFS_ENV - ) - })); + assert!(prepared.inner.env.is_none()); let legacy = super::prepare_request(&request).unwrap(); - assert!(!legacy - .inner - .env - .unwrap_or_default() - .iter() - .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=off")); + assert_eq!(legacy.inner.mount_procfs, None); let explicit_mount = super::prepare_request_with_procfs_policy( &request, crate::options::ProcfsPolicy::Mount, ) .unwrap(); + assert_eq!(explicit_mount.inner.mount_procfs, Some(true)); assert!(!explicit_mount .inner .policy .denied_paths .iter() .any(|path| path == "/proc")); - assert!(explicit_mount - .inner - .env - .unwrap() - .iter() - .any(|entry| entry == "MXC_INTERNAL_MOUNT_PROCFS=on")); + assert!(explicit_mount.inner.env.is_none()); // Omission is an independent procfs selection, not a filesystem // denial: with no explicit grant, `/proc` remains absent from both @@ -1395,11 +1368,20 @@ mod tests { crate::options::ProcfsPolicy::Omit, ) .unwrap(); - assert!(!omitted.inner.policy.denied_paths.iter().any(|path| path == "/proc")); - assert!(!omitted.inner.policy.readonly_paths.iter().any(|path| path == "/proc")); - assert!(omitted.inner.env.as_ref().unwrap().iter().any(|entry| { - entry == "MXC_INTERNAL_MOUNT_PROCFS=off" - })); + assert_eq!(omitted.inner.mount_procfs, Some(false)); + assert!(!omitted + .inner + .policy + .denied_paths + .iter() + .any(|path| path == "/proc")); + assert!(!omitted + .inner + .policy + .readonly_paths + .iter() + .any(|path| path == "/proc")); + assert!(omitted.inner.env.is_none()); let mut spoofed = super::ContainerRequest::new("/usr/bin/true"); spoofed.containment = super::Containment::Bubblewrap; From d9b055d223c176b559393dd06ffea0bb07b7c379 Mon Sep 17 00:00:00 2001 From: Wes Turner Date: Sat, 24 Oct 2026 08:37:06 +0000 Subject: [PATCH 9/9] test(fuzz): update fuzz run time cmd in Bubblewrap backend documentation --- docs/backends/bwrap/bubblewrap-backend.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/backends/bwrap/bubblewrap-backend.md b/docs/backends/bwrap/bubblewrap-backend.md index 50bedb4e9..fde0fdd69 100644 --- a/docs/backends/bwrap/bubblewrap-backend.md +++ b/docs/backends/bwrap/bubblewrap-backend.md @@ -194,11 +194,11 @@ 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 + -max_total_time=300 -max_len=4096 ``` -The 2026-10-15 Linux run completed 2,276,062 executions in 31 seconds without a -crash. Curated JSON inputs under +The 2026-10-24 Linux rerun reached the 300-second time limit after more than +16.4 million executions, 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.