From cfc8f5c1714ce6a7d7e5bb3029880bbe1b657352 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 3 Jun 2026 09:39:48 -0300 Subject: [PATCH 1/6] fix(bwrap): default to deny-by-default filesystem The Bubblewrap backend used to bind-mount the entire host root read-only into every sandbox (`--ro-bind / /`), so the caller's $HOME, /root, /opt, /var/sys, /run/user/, and everything else readable by the calling uid was visible inside the sandbox by default. The macOS Seatbelt backend, by contrast, starts from `(deny default)` and only allows a narrow system baseline -- bwrap now matches that posture. The new baseline (`BASELINE_RO_BIND_PATHS`) mirrors seatbelt's `SYSTEM_READ_ALLOW` allowlist: top-level executable/library dirs (/bin, /sbin, /lib*), the /usr subpaths that seatbelt allows (without /usr/local), /etc, and the DNS stub-resolver directories under /run (/run/systemd/resolve, /run/NetworkManager, /run/resolvconf) so /etc/resolv.conf symlinks still resolve when network is allowed. $HOME, /opt, /usr/local, /var, /sys, and /run/user/ are no longer visible until the caller opts in via `readonlyPaths` / `readwritePaths`. Paths are emitted via `--ro-bind-try` so missing entries are silently skipped (e.g. /lib32 on x86_64-only systems, /run/systemd/resolve on hosts without systemd-resolved). Files in /etc with restrictive perms (/etc/shadow, /etc/sudoers, /etc/ssh/ssh_host_*_key) remain unreadable to a non-root caller even though /etc is bound whole -- user-namespace UID mapping does not bypass kernel DAC. Updated the existing `filesystem_policy_produces_correct_mounts` test and added 5 new tests covering the new contract (no host-root bind, required baseline paths emitted, /usr/local not exposed, confidential paths excluded, DNS dirs included, baseline precedes policy mounts). Docs in docs/bwrap-support/bubblewrap-backend.md updated accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker --- docs/bwrap-support/bubblewrap-backend.md | 50 +++- .../bubblewrap/common/src/bwrap_command.rs | 247 ++++++++++++++++-- 2 files changed, 277 insertions(+), 20 deletions(-) diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 123c5992d..c3cecb7c9 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -54,7 +54,12 @@ lxc-exec --experimental --config-base64 "$(base64 -w0 bubblewrap_hello.json)" Bubblewrap creates a namespace-isolated process by: 1. Unsharing user, PID, IPC, and UTS namespaces (`--unshare-*`) -2. Bind-mounting the host root filesystem read-only as a base +2. Bind-mounting a **minimal deny-by-default baseline** read-only into the + sandbox (`/bin`, `/sbin`, `/lib*`, `/usr/bin`, `/usr/sbin`, `/usr/lib*`, + `/usr/libexec`, `/usr/share`, `/etc`, plus DNS stub-resolver dirs + under `/run`). Everything else on the host — including the caller's + `$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` 5. Clearing the environment and applying only requested variables @@ -63,6 +68,41 @@ Bubblewrap creates a namespace-isolated process by: The sandboxed process runs as a child of `bwrap` and dies automatically when execution completes — no container lifecycle management required. +### Deny-by-default filesystem + +The baseline mirrors the macOS Seatbelt backend's `(deny default)` posture: +the sandbox can read the dynamic linker, libc, system tools, and system +configuration — and **nothing else** — until the caller opts in via +`readonlyPaths` / `readwritePaths`. To make a host directory visible inside +the sandbox, list it explicitly: + +```json +{ + "filesystem": { + "readonlyPaths": ["/home/alice/project", "/usr/local"], + "readwritePaths": ["/tmp/workspace"] + } +} +``` + +Common consequences of this default: + +- `$HOME` (e.g. `~/.aws/credentials`, `~/.ssh/id_*`, browser cookies) is + not readable from the sandbox. +- `/opt` and `/usr/local` tooling is not on PATH; list either path under + `readonlyPaths` if the script depends on it. +- `working_directory` must live under the baseline or a policy path — a + `cwd` of `~/project` without a matching `readonlyPaths` entry will fail. +- DNS works on systemd-resolved, NetworkManager, and resolvconf hosts + because the corresponding `/run/...` directories are bound. Hosts where + `/etc/resolv.conf` symlinks somewhere else need that target listed in + `readonlyPaths`. + +Files in `/etc` that contain secrets (`/etc/shadow`, `/etc/sudoers`, +`/etc/ssh/ssh_host_*_key`) are mode `0400` / `0640` `root` and remain +unreadable to a non-root caller — user-namespace UID mapping does not +bypass kernel DAC. + ## Configuration Bubblewrap uses the shared cross-backend configuration fields. No @@ -285,8 +325,12 @@ Test configs are in `tests/configs/bubblewrap_*.json`. - **Experimental** — requires `--experimental` flag - **Linux only** — Bubblewrap requires Linux kernel namespaces -- **Host filesystem** — the sandbox sees the host's files (read-only by - default); there is no separate rootfs +- **Deny-by-default filesystem** — the sandbox sees a minimal allowlist + of host paths (system binaries, libs, `/etc`, DNS stub-resolver dirs) + and nothing else. `$HOME`, `/opt`, `/var`, `/sys`, `/run/user/`, + and `/usr/local` are invisible unless explicitly listed in + `readonlyPaths` / `readwritePaths`. There is no separate rootfs — the + visible paths are bind-mounted from the host. - **Network filtering** — per-host `allowedHosts`/`blockedHosts` is best done via the cooperative env-var **network proxy** (no privilege required, see above). The legacy iptables path diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index 823f5ac90..90266d0b7 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -22,6 +22,69 @@ const PROXY_ENV_KEYS: &[&str] = &[ "no_proxy", ]; +/// Read-only host paths bind-mounted into every Bubblewrap sandbox as the +/// deny-by-default baseline. Mirrors the seatbelt backend's +/// `SYSTEM_READ_ALLOW` (`src/backends/seatbelt/common/src/profile_builder.rs`): +/// just enough of the host for a shell, the dynamic linker, libc, and +/// system tools to work. Everything else — including the caller's `$HOME`, +/// `/root`, `/opt`, `/var`, `/sys`, `/mnt`, `/media`, and the rest of +/// `/run` — is invisible until the caller opts in via `readonlyPaths` / +/// `readwritePaths`. +/// +/// Notes: +/// - Missing paths are silently skipped because the runner emits these +/// via `--ro-bind-try` (e.g. `/lib32` does not exist on x86_64-only +/// systems; `/run/systemd/resolve` does not exist on hosts without +/// systemd-resolved). +/// - On merged-usr distros (modern Debian, Ubuntu, Fedora, Arch) the +/// top-level `/bin`, `/sbin`, `/lib*` entries are symlinks pointing +/// under `/usr`. `bwrap` follows the source-side symlink, so the +/// bind-mount still succeeds and the sandbox sees `/bin/sh` etc. +/// - We deliberately do NOT bind `/usr` wholesale: that would expose +/// `/usr/local`, which contains locally-installed (and sometimes +/// user-managed) software. Callers who need `/usr/local` must list it +/// explicitly in `readonlyPaths`. +/// - We deliberately do NOT bind `/run` wholesale: `/run/user/` +/// holds the caller's D-Bus session socket, keyring sockets, and +/// ssh-agent socket. We only bind the well-known DNS stub-resolver +/// directories so name resolution still works when `/etc/resolv.conf` +/// is a symlink (the default on systemd-resolved hosts). +/// - `/etc` is bound whole because cherry-picking files (`passwd`, +/// `nsswitch.conf`, `ssl/`, `ld.so.conf*`, …) is fragile and breaks +/// tools that read other config files. Files with sensitive contents +/// (`/etc/shadow`, `/etc/sudoers`, `/etc/ssh/ssh_host_*_key`) are mode +/// `0400` / `0640` root and remain unreadable to a non-root caller — +/// user-namespace UID mapping does not bypass kernel DAC. +const BASELINE_RO_BIND_PATHS: &[&str] = &[ + // Top-level executable / library dirs (symlinks under /usr on + // merged-usr distros, real directories on Alpine and older Debian). + "/bin", + "/sbin", + "/lib", + "/lib32", + "/lib64", + "/libx32", + // /usr subpaths — mirrors seatbelt's baseline exactly, intentionally + // excluding /usr/local. + "/usr/bin", + "/usr/sbin", + "/usr/lib", + "/usr/lib32", + "/usr/lib64", + "/usr/libexec", + "/usr/share", + // System configuration (ld.so config, certs, resolv.conf, hosts, + // passwd, group, machine-id, …). See module-level note on DAC. + "/etc", + // DNS stub-resolver directories. /etc/resolv.conf is usually a + // symlink into one of these on modern Linux distros (systemd-resolved + // / NetworkManager / resolvconf). We bind the narrow subdirectories + // rather than all of /run to avoid exposing /run/user/. + "/run/systemd/resolve", + "/run/NetworkManager", + "/run/resolvconf", +]; + /// Build the complete argument list for `bwrap` from the given request. /// /// The returned vector does **not** include the `bwrap` binary name itself — @@ -62,13 +125,15 @@ pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddres args.push("--unshare-net".into()); } - // -- Base filesystem --------------------------------------------------- + // -- Base filesystem (deny-by-default; see `BASELINE_RO_BIND_PATHS`) --- // bwrap applies mounts in order; later mounts at the same path shadow - // earlier ones. We therefore lay down the base + standard virtual - // filesystems first, then apply user-supplied policy mounts last so they - // always win, including when policy paths overlap a standard mount such - // as `/tmp` (e.g. `readwritePaths: ["/tmp/workspace"]`). - args.extend(["--ro-bind".into(), "/".into(), "/".into()]); + // earlier ones. We therefore lay the baseline + standard virtual + // filesystems down first, then apply user-supplied policy mounts last + // so they always win when paths overlap (e.g. `readwritePaths: + // ["/tmp/workspace"]` must beat the standard `--tmpfs /tmp`). + for path in BASELINE_RO_BIND_PATHS { + args.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]); + } // Standard virtual filesystems (applied before policy mounts so policy // paths under /dev, /proc, or /tmp survive). @@ -206,17 +271,13 @@ mod tests { assert_eq!(args[rw_pos + 1], "/workspace"); assert_eq!(args[rw_pos + 2], "/workspace"); - // ro - let ro_positions: Vec<_> = args - .iter() - .enumerate() - .filter(|(_, a)| *a == "--ro-bind") - .map(|(i, _)| i) - .collect(); - // First --ro-bind is the base "/" mount, second is "/data" - assert!(ro_positions.len() >= 2); - let data_pos = *ro_positions.last().unwrap(); - assert_eq!(args[data_pos + 1], "/data"); + // ro — baseline paths are emitted via --ro-bind-try, so a bare + // --ro-bind must correspond to the user's readonlyPaths entry. + let ro_pos = args + .windows(3) + .position(|w| w[0] == "--ro-bind" && w[1] == "/data" && w[2] == "/data") + .expect("readonly policy path /data should produce a --ro-bind mount"); + assert!(ro_pos > 0); // denied let tmpfs_positions: Vec<_> = args @@ -401,4 +462,156 @@ mod tests { let pos = args.iter().position(|a| a == "HTTP_PROXY").unwrap(); assert_eq!(args[pos + 1], "http://caller.example:8080"); } + + // ------- Deny-by-default baseline filesystem tests ------------------ + + /// Regression test for the original `--ro-bind / /` baseline. The + /// builder must NOT bind-mount the entire host root, because that + /// exposed `$HOME` and other confidential dirs by default. Mirrors + /// the seatbelt backend's `(deny default)` posture. + #[test] + fn baseline_does_not_bind_mount_host_root() { + let args = build_args(&base_request(), None); + let root_bind = args + .windows(3) + .any(|w| (w[0] == "--ro-bind" || w[0] == "--bind") && w[1] == "/" && w[2] == "/"); + assert!( + !root_bind, + "baseline must not bind-mount host / into the sandbox; got: {:?}", + args + ); + } + + /// The minimum baseline allowlist required for a shell + dynamic + /// linker + libc to function inside the sandbox. Emitted via + /// `--ro-bind-try` so missing paths are silently skipped on distros + /// where they don't exist (e.g. `/lib32` on x86_64-only systems). + #[test] + fn baseline_emits_required_ro_bind_try_paths() { + let args = build_args(&base_request(), None); + let required = [ + "/bin", + "/sbin", + "/lib", + "/lib64", + "/usr/bin", + "/usr/lib", + "/usr/share", + "/etc", + ]; + for path in required { + let found = args + .windows(3) + .any(|w| w[0] == "--ro-bind-try" && w[1] == path && w[2] == path); + assert!( + found, + "baseline must emit `--ro-bind-try {} {}` so sandboxed processes \ + can find sh / libc / system config", + path, path + ); + } + } + + /// The baseline must NOT include `/usr` wholesale because that would + /// expose `/usr/local` (locally-installed software, sometimes + /// user-managed). Seatbelt's `SYSTEM_READ_ALLOW` does not include + /// `/usr/local` either — match that posture. + #[test] + fn baseline_does_not_expose_usr_local() { + let args = build_args(&base_request(), None); + // No `--ro-bind /usr /usr` and no `--ro-bind-try /usr /usr`. + let usr_whole = args + .windows(3) + .any(|w| matches!(w[0].as_str(), "--ro-bind" | "--ro-bind-try") && w[1] == "/usr"); + assert!( + !usr_whole, + "baseline must bind /usr subpaths individually so /usr/local is \ + not implicitly exposed; got: {:?}", + args + ); + // And no explicit /usr/local entry either. + let usr_local = args.iter().any(|a| a == "/usr/local"); + assert!(!usr_local, "baseline must not expose /usr/local by default"); + } + + /// The baseline must keep confidential host locations out of the + /// sandbox. Callers who legitimately need any of these can opt in + /// via `readonlyPaths`. + #[test] + fn baseline_excludes_confidential_paths() { + let args = build_args(&base_request(), None); + for forbidden in [ + "/home", + "/root", + "/opt", + "/srv", + "/var", + "/sys", + "/run/user", + "/run/dbus", + ] { + let exposed = args.windows(2).any(|w| { + matches!(w[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try") + && w[1] == forbidden + }); + assert!( + !exposed, + "baseline must not bind-mount {} — that would re-expose \ + confidential host state", + forbidden + ); + } + } + + /// DNS stub-resolver dirs must be in the baseline so `/etc/resolv.conf` + /// symlinks resolve when the caller has network access. Emitted via + /// `--ro-bind-try` so hosts without systemd-resolved / NetworkManager / + /// resolvconf still build a valid argument vector. + #[test] + fn baseline_includes_dns_stub_resolver_dirs() { + let args = build_args(&base_request(), None); + for path in [ + "/run/systemd/resolve", + "/run/NetworkManager", + "/run/resolvconf", + ] { + let found = args + .windows(3) + .any(|w| w[0] == "--ro-bind-try" && w[1] == path && w[2] == path); + assert!( + found, + "baseline must emit `--ro-bind-try {} {}` so DNS works when \ + /etc/resolv.conf is a symlink", + path, path + ); + } + } + + /// Baseline mounts must come before policy mounts so the user's + /// `readwritePaths` / `readonlyPaths` / `deniedPaths` always win on + /// conflict (same shadowing rule as the existing `/tmp` regression + /// test, applied here to the baseline). + #[test] + fn baseline_mounts_precede_policy_mounts() { + let mut r = base_request(); + r.policy.readwrite_paths = vec!["/etc/policy-writable".into()]; + let args = build_args(&r, None); + + let baseline_etc = args + .windows(3) + .position(|w| w[0] == "--ro-bind-try" && w[1] == "/etc" && w[2] == "/etc") + .expect("baseline /etc bind missing"); + let policy_bind = args + .windows(3) + .position(|w| w[0] == "--bind" && w[1] == "/etc/policy-writable") + .expect("policy bind missing"); + + assert!( + policy_bind > baseline_etc, + "policy mount at /etc/policy-writable (pos {}) must come after \ + baseline /etc bind (pos {}) so the policy mount wins", + policy_bind, + baseline_etc + ); + } } From e679258279edb2c451d3c251183f9487354651c0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 3 Jun 2026 09:39:57 -0300 Subject: [PATCH 2/6] fix(nanvix): compile as build-dep from non-Linux/Windows hosts `nanvix_common` is a `[build-dependency]` of `lxc` and `wxc`. Build deps are compiled for the host, so cross-compiling lxc-exec from macOS to aarch64-unknown-linux-gnu pulled nanvix_common into a host build where `target_os` was neither "windows" nor "linux" -- the `REQUIRED_BINARIES` and `NANVIXD_BINARY` constants then had no definition and the crate failed to compile. Add empty/zero fallbacks for non-Windows/Linux hosts. The empty slice is correct because: - NanVix only runs on Windows and Linux, so iterating `REQUIRED_BINARIES` on other hosts must be a no-op. - The consuming build scripts (e.g. `src/core/lxc/build.rs`) already gate the surrounding logic behind `cfg(target_os = "linux")` and `feature = "microvm"`, so the fallback values are never reached in practice. Zero runtime impact on supported platforms; pure build-time portability fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker --- src/backends/nanvix/common/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/backends/nanvix/common/src/lib.rs b/src/backends/nanvix/common/src/lib.rs index f5d974cd4..3cb16b40a 100644 --- a/src/backends/nanvix/common/src/lib.rs +++ b/src/backends/nanvix/common/src/lib.rs @@ -20,6 +20,16 @@ pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.exe", "nanvix_rootfs.img", "py #[cfg(target_os = "linux")] pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"]; +/// Fallback for non-Windows/Linux hosts so the crate compiles when it is +/// pulled in as a `[build-dependency]` from a macOS / BSD host that is +/// cross-compiling to a supported target. NanVix only runs on Windows +/// and Linux, so the empty slice is correct: any caller that iterates +/// `REQUIRED_BINARIES` does nothing, and the higher-level Linux/Windows +/// `target_os` cfg gates in the consuming binaries (e.g. `lxc/build.rs`) +/// ensure the surrounding logic is only invoked on supported hosts. +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +pub const REQUIRED_BINARIES: &[&str] = &[]; + /// NanVix daemon binary name (platform-conditional). #[cfg(target_os = "windows")] pub const NANVIXD_BINARY: &str = "nanvixd.exe"; @@ -28,6 +38,10 @@ pub const NANVIXD_BINARY: &str = "nanvixd.exe"; #[cfg(target_os = "linux")] pub const NANVIXD_BINARY: &str = "nanvixd.elf"; +/// Fallback for non-Windows/Linux hosts. See `REQUIRED_BINARIES` above. +#[cfg(not(any(target_os = "windows", target_os = "linux")))] +pub const NANVIXD_BINARY: &str = ""; + /// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. pub const INITRD_BINARY: &str = "python3.initrd"; From 6e31d2a1a9a22675596ae5f6bac5ed98f80f7c89 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Tue, 9 Jun 2026 08:35:20 -0300 Subject: [PATCH 3/6] fix(bwrap): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nanvix: use a descriptive sentinel for NANVIXD_BINARY on unsupported hosts instead of an empty string, so any accidental Command use fails with a named program rather than an empty one. - bwrap: soften "mirrors seatbelt's baseline exactly" comment to "aligned with" to avoid implying exact, lasting parity. - bwrap test: drop the brittle `assert!(ro_pos > 0)` — the preceding `.expect(...)` already guarantees the mount exists. - bwrap test: restrict the /usr/local check to mount-argument windows so a script body mentioning /usr/local cannot cause a false positive. - docs: note the deny-by-default baseline requires bwrap 0.3.0+ for `--ro-bind-try`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker --- docs/bwrap-support/bubblewrap-backend.md | 3 +++ .../bubblewrap/common/src/bwrap_command.rs | 15 +++++++++------ src/backends/nanvix/common/src/lib.rs | 6 +++++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index c3cecb7c9..994cb3954 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -21,6 +21,9 @@ requiring root privileges or a container runtime. # Alpine apk add bubblewrap ``` + The deny-by-default baseline (see [How It Works](#how-it-works)) emits its + read-only mounts via `--ro-bind-try`, which requires **bwrap 0.3.0+** + (released 2017; every currently-supported distro ships a newer version). - User namespaces must be enabled: ```bash # Check: should print "1" diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index 90266d0b7..868d4362f 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -64,7 +64,7 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[ "/lib32", "/lib64", "/libx32", - // /usr subpaths — mirrors seatbelt's baseline exactly, intentionally + // /usr subpaths — aligned with seatbelt's baseline, intentionally // excluding /usr/local. "/usr/bin", "/usr/sbin", @@ -273,11 +273,9 @@ mod tests { // ro — baseline paths are emitted via --ro-bind-try, so a bare // --ro-bind must correspond to the user's readonlyPaths entry. - let ro_pos = args - .windows(3) + args.windows(3) .position(|w| w[0] == "--ro-bind" && w[1] == "/data" && w[2] == "/data") .expect("readonly policy path /data should produce a --ro-bind mount"); - assert!(ro_pos > 0); // denied let tmpfs_positions: Vec<_> = args @@ -529,8 +527,13 @@ mod tests { not implicitly exposed; got: {:?}", args ); - // And no explicit /usr/local entry either. - let usr_local = args.iter().any(|a| a == "/usr/local"); + // And no explicit /usr/local mount either. Restrict the scan to + // mount-argument windows so a script body that merely mentions + // `/usr/local` cannot trigger a false positive. + let usr_local = args.windows(3).any(|w| { + matches!(w[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try") + && w[1] == "/usr/local" + }); assert!(!usr_local, "baseline must not expose /usr/local by default"); } diff --git a/src/backends/nanvix/common/src/lib.rs b/src/backends/nanvix/common/src/lib.rs index 3cb16b40a..6be56510f 100644 --- a/src/backends/nanvix/common/src/lib.rs +++ b/src/backends/nanvix/common/src/lib.rs @@ -39,8 +39,12 @@ pub const NANVIXD_BINARY: &str = "nanvixd.exe"; pub const NANVIXD_BINARY: &str = "nanvixd.elf"; /// Fallback for non-Windows/Linux hosts. See `REQUIRED_BINARIES` above. +/// A descriptive sentinel (rather than an empty string) so that if any +/// code path ever does construct a `Command` from this on an unsupported +/// host, the failure clearly names the cause instead of reporting an +/// empty program name. #[cfg(not(any(target_os = "windows", target_os = "linux")))] -pub const NANVIXD_BINARY: &str = ""; +pub const NANVIXD_BINARY: &str = "nanvixd-unsupported-host"; /// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. pub const INITRD_BINARY: &str = "python3.initrd"; From 3c7c345ccdbf9f05ad12dcd584599b003419bc8b Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 11 Jun 2026 10:09:55 -0300 Subject: [PATCH 4/6] ci: re-trigger pipeline The previous MXC-PR-Build run (149353501) failed only on the Linux 1ES agents with transient network errors in the same time window: - SDK Unit Tests (linux): "The SSL connection could not be established" - x64/arm64 LXC builds: cargo exited 101 (dependency fetch failure) All equivalent Windows/macOS jobs passed, and the exact Linux build/test commands plus the SDK unit tests reproduce cleanly and pass locally, so the branch changes are not the cause. Empty commit to re-run CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker From 80059506f47a17514bfd666399f593b7f1bbc483 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 17 Jun 2026 11:10:34 -0300 Subject: [PATCH 5/6] fix: merge conflict Signed-off-by: Carlos Alexandro Becker --- src/backends/nanvix/common/src/lib.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/backends/nanvix/common/src/lib.rs b/src/backends/nanvix/common/src/lib.rs index 0b9808ed4..67c4ea2ce 100644 --- a/src/backends/nanvix/common/src/lib.rs +++ b/src/backends/nanvix/common/src/lib.rs @@ -20,16 +20,6 @@ pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.exe", "nanvix_rootfs.img", "py #[cfg(target_os = "linux")] pub const REQUIRED_BINARIES: &[&str] = &["nanvixd.elf", "nanvix_rootfs.img", "python3.initrd"]; -/// Fallback for non-Windows/Linux hosts so the crate compiles when it is -/// pulled in as a `[build-dependency]` from a macOS / BSD host that is -/// cross-compiling to a supported target. NanVix only runs on Windows -/// and Linux, so the empty slice is correct: any caller that iterates -/// `REQUIRED_BINARIES` does nothing, and the higher-level Linux/Windows -/// `target_os` cfg gates in the consuming binaries (e.g. `lxc/build.rs`) -/// ensure the surrounding logic is only invoked on supported hosts. -#[cfg(not(any(target_os = "windows", target_os = "linux")))] -pub const REQUIRED_BINARIES: &[&str] = &[]; - /// NanVix daemon binary name (platform-conditional). #[cfg(target_os = "windows")] pub const NANVIXD_BINARY: &str = "nanvixd.exe"; @@ -38,14 +28,6 @@ pub const NANVIXD_BINARY: &str = "nanvixd.exe"; #[cfg(target_os = "linux")] pub const NANVIXD_BINARY: &str = "nanvixd.elf"; -/// Fallback for non-Windows/Linux hosts. See `REQUIRED_BINARIES` above. -/// A descriptive sentinel (rather than an empty string) so that if any -/// code path ever does construct a `Command` from this on an unsupported -/// host, the failure clearly names the cause instead of reporting an -/// empty program name. -#[cfg(not(any(target_os = "windows", target_os = "linux")))] -pub const NANVIXD_BINARY: &str = "nanvixd-unsupported-host"; - /// Multi-binary initrd (daemons + CPython) loaded by NanVix at warm start. pub const INITRD_BINARY: &str = "python3.initrd"; From 9ae2486bbed5496c62052ed20b94af7a3b3c2141 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 19 Jun 2026 12:01:22 -0300 Subject: [PATCH 6/6] fix(bwrap): keep DNS working when /etc/resolv.conf points outside the baseline The deny-by-default baseline never mounts /var or /mnt, so an /etc/resolv.conf symlink routed through /var/run/... (older RHEL/CentOS, some container images) or /mnt/wsl/resolv.conf (WSL) would dangle inside the sandbox and silently break name resolution. Cover the two common out-of-baseline targets without exposing host /var or /mnt contents: - synthesise a `/var/run -> /run` compat symlink so /var/run/...-routed resolv.conf targets resolve into the already-bound /run/* DNS dirs; - `--ro-bind-try` /mnt/wsl/resolv.conf so WSL DNS works (skipped on non-WSL hosts). Add regression tests for both and update the backend docs. Verified the symlink/bind behavior empirically with bwrap 0.8.0. Addresses review feedback from @MGudgin on PR #482. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Carlos Alexandro Becker --- docs/bwrap-support/bubblewrap-backend.md | 10 ++- .../bubblewrap/common/src/bwrap_command.rs | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs/bwrap-support/bubblewrap-backend.md b/docs/bwrap-support/bubblewrap-backend.md index 28c952c35..9c8db5c0d 100644 --- a/docs/bwrap-support/bubblewrap-backend.md +++ b/docs/bwrap-support/bubblewrap-backend.md @@ -97,9 +97,13 @@ Common consequences of this default: - `working_directory` must live under the baseline or a policy path — a `cwd` of `~/project` without a matching `readonlyPaths` entry will fail. - DNS works on systemd-resolved, NetworkManager, and resolvconf hosts - because the corresponding `/run/...` directories are bound. Hosts where - `/etc/resolv.conf` symlinks somewhere else need that target listed in - `readonlyPaths`. + because the corresponding `/run/...` directories are bound. The common + symlink targets *outside* `/run` are covered too: `/var/run/...`-routed + `/etc/resolv.conf` symlinks resolve via a synthesised `/var/run -> /run` + compat symlink, and WSL's `/mnt/wsl/resolv.conf` is bound directly. + Neither exposes host `/var` or `/mnt` contents. Hosts that point + `/etc/resolv.conf` at some other custom location still need that target + listed in `readonlyPaths`. Files in `/etc` that contain secrets (`/etc/shadow`, `/etc/sudoers`, `/etc/ssh/ssh_host_*_key`) are mode `0400` / `0640` `root` and remain diff --git a/src/backends/bubblewrap/common/src/bwrap_command.rs b/src/backends/bubblewrap/common/src/bwrap_command.rs index 868d4362f..daf1bfa4c 100644 --- a/src/backends/bubblewrap/common/src/bwrap_command.rs +++ b/src/backends/bubblewrap/common/src/bwrap_command.rs @@ -49,6 +49,12 @@ const PROXY_ENV_KEYS: &[&str] = &[ /// ssh-agent socket. We only bind the well-known DNS stub-resolver /// directories so name resolution still works when `/etc/resolv.conf` /// is a symlink (the default on systemd-resolved hosts). +/// - To keep DNS working when `/etc/resolv.conf` points *outside* those +/// dirs, we also synthesise a `/var/run -> /run` compat symlink (for +/// `/var/run/...`-routed targets — older RHEL/CentOS-era and some +/// container images) and `--ro-bind-try` `/mnt/wsl/resolv.conf` (for +/// WSL). Neither exposes host `/var` or `/mnt` contents — only the +/// resolver path itself. /// - `/etc` is bound whole because cherry-picking files (`passwd`, /// `nsswitch.conf`, `ssl/`, `ld.so.conf*`, …) is fragile and breaks /// tools that read other config files. Files with sensitive contents @@ -83,6 +89,11 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[ "/run/systemd/resolve", "/run/NetworkManager", "/run/resolvconf", + // WSL generates its resolv.conf here and points /etc/resolv.conf at + // it. Bind just this single file (not /mnt) so DNS works under WSL + // without exposing the Windows drive mounts. Skipped on non-WSL hosts + // because the baseline is emitted via `--ro-bind-try`. + "/mnt/wsl/resolv.conf", ]; /// Build the complete argument list for `bwrap` from the given request. @@ -135,6 +146,15 @@ pub fn build_args(request: &ExecutionRequest, proxy_address: Option<&ProxyAddres args.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]); } + // Recreate the standard `/var/run -> /run` compatibility symlink. Some + // distros (older RHEL/CentOS-era, some container images) write + // `/etc/resolv.conf` as a symlink routed through `/var/run/...` (e.g. + // `/var/run/NetworkManager/resolv.conf`). We never mount `/var`, so that + // intermediate path would dangle inside the sandbox and DNS would + // silently fail. The symlink rescues the whole `/var/run/...` family and + // pulls no host `/var` contents in (bwrap synthesises an empty `/var`). + args.extend(["--symlink".into(), "/run".into(), "/var/run".into()]); + // Standard virtual filesystems (applied before policy mounts so policy // paths under /dev, /proc, or /tmp survive). args.extend(["--dev".into(), "/dev".into()]); @@ -590,6 +610,60 @@ mod tests { } } + /// Regression test for the `/etc/resolv.conf -> /var/run/.../resolv.conf` + /// symlink case (older RHEL/CentOS-era, some container images). We never + /// mount `/var`, so without a `/var/run -> /run` compat symlink the + /// target dangles and DNS silently breaks. Assert the symlink is emitted + /// so `/var/run/NetworkManager/resolv.conf` resolves into the bound + /// `/run/NetworkManager`. + #[test] + fn baseline_recreates_var_run_compat_symlink() { + let args = build_args(&base_request(), None); + let found = args + .windows(3) + .any(|w| w[0] == "--symlink" && w[1] == "/run" && w[2] == "/var/run"); + assert!( + found, + "baseline must emit `--symlink /run /var/run` so /etc/resolv.conf \ + symlinks routed through /var/run/... resolve; got: {:?}", + args + ); + // The compat symlink must not drag a host /var bind in with it. + let var_bound = args.windows(2).any(|w| { + matches!(w[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try") && w[1] == "/var" + }); + assert!(!var_bound, "compat symlink must not bind host /var"); + } + + /// Regression test for WSL, where `/etc/resolv.conf` points at + /// `/mnt/wsl/resolv.conf`. We bind that single file (via `--ro-bind-try`, + /// so it is skipped on non-WSL hosts) without exposing the rest of + /// `/mnt`. + #[test] + fn baseline_includes_wsl_resolv_conf() { + let args = build_args(&base_request(), None); + let found = args.windows(3).any(|w| { + w[0] == "--ro-bind-try" + && w[1] == "/mnt/wsl/resolv.conf" + && w[2] == "/mnt/wsl/resolv.conf" + }); + assert!( + found, + "baseline must emit `--ro-bind-try /mnt/wsl/resolv.conf ...` so DNS \ + works under WSL; got: {:?}", + args + ); + // Only the single resolv.conf file — never /mnt or /mnt/wsl wholesale. + let mnt_whole = args.windows(2).any(|w| { + matches!(w[0].as_str(), "--bind" | "--ro-bind" | "--ro-bind-try") + && (w[1] == "/mnt" || w[1] == "/mnt/wsl") + }); + assert!( + !mnt_whole, + "baseline must not expose /mnt or /mnt/wsl wholesale" + ); + } + /// Baseline mounts must come before policy mounts so the user's /// `readwritePaths` / `readonlyPaths` / `deniedPaths` always win on /// conflict (same shadowing rule as the existing `/tmp` regression