Skip to content
Closed
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
33 changes: 30 additions & 3 deletions docs/bwrap-support/bubblewrap-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,43 @@ Common consequences of this default:
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.
- An absolute `process.cwd` must resolve inside the baseline or filesystem
policy. The runner rejects paths that are provably uncovered and returns an
actionable error naming `filesystem.readonlyPaths` /
`filesystem.readwritePaths`. Symlinked paths and paths containing `..` are
advisory: the runner records that it cannot decide conclusively and leaves
Bubblewrap as the authority, avoiding false rejection of a valid namespace
path. Relative values remain passed through to `bwrap` unchanged.
- The synthetic roots `/`, `/tmp`, and `/var` exist without a policy mount, but
arbitrary descendants below `/tmp` or `/var` do not. `/dev` and `/proc` are
mounted virtual filesystems. Every policy operation, including a denied mask,
creates its destination's synthetic parent directories. Most-specific-path-
wins policy still determines whether the cwd itself is allowed.
- The baseline creates `/var/run` as a symlink to `/run`. Cwd values that
name `/var/run` or one of its descendants are evaluated against the
corresponding `/run` namespace path, independently of whether the host uses
the same symlink while that synthetic link remains active. A policy mount at
`/var/run` or one of its ancestors shadows the link; preflight then evaluates
the host-backed `/var` topology instead, conservatively deferring when its
symlinks cannot be modeled. Later policy mounts retain their normal order.
Parent traversal and other host-symlink ambiguity are left to Bubblewrap. A
clear denied-path conflict is rejected and names the covering `deniedPaths`
entry; remove or narrow that entry rather than adding an equal or shallower
allow path.
- DNS works on systemd-resolved, NetworkManager, and resolvconf hosts
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`.
listed in `readonlyPaths`; the runner identifies the required policy field
without returning the resolved host path. Inspect `/etc/resolv.conf` on the
host (for example with `readlink -f`) to identify the target.
- If the runner cannot inspect `/etc/resolv.conf` or resolve its symlink
target, it emits a separate warning. Repair the host resolver path before
retrying; a filesystem grant cannot correct an unreadable or broken host
symlink.

Files in `/etc` that contain secrets (`/etc/shadow`, `/etc/sudoers`,
`/etc/ssh/ssh_host_*_key`) are mode `0400` / `0640` `root` and remain
Expand Down
9 changes: 9 additions & 0 deletions docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ use:
Policy entries that are blank, name a file, or do not exist yet are skipped:
a process cannot be launched in any of them.

Bubblewrap preflights an absolute `process.cwd` against the namespace it is
about to assemble. A path that is provably outside both the backend baseline
and the configured filesystem policy is rejected with an error naming
`readonlyPaths` and `readwritePaths`, rather than being left to fail opaquely
inside `bwrap`. Symlinked paths and paths containing parent traversal are
reported as inconclusive and deferred to Bubblewrap so the preflight cannot
reject a valid namespace path. Relative values retain the general verbatim
behavior described above.
Comment on lines +297 to +298

### Filesystem Policy

The `filesystem` section defines path access policy shared across backends:
Expand Down
56 changes: 24 additions & 32 deletions src/backends/bubblewrap/common/src/bwrap_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub(crate) const COMMAND_TAIL: [&str; 3] = ["--", "sh", "-c"];
/// (`/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] = &[
pub(crate) 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",
Expand Down Expand Up @@ -97,6 +97,16 @@ const BASELINE_RO_BIND_PATHS: &[&str] = &[
"/mnt/wsl/resolv.conf",
];

/// Fixed paths created by the Bubblewrap baseline.
///
/// The argument builder and filesystem preflight both consume these constants
/// so a topology change cannot silently update one without the other.
pub(crate) const DEV_PATH: &str = "/dev";
pub(crate) const PROC_PATH: &str = "/proc";
pub(crate) const TMP_PATH: &str = "/tmp";
pub(crate) const VAR_RUN_COMPAT_TARGET: &str = "/run";
pub(crate) const VAR_RUN_COMPAT_LINK: &str = "/var/run";

/// The networking behavior Bubblewrap applies for one execution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ResolvedNetworkMode {
Expand Down Expand Up @@ -679,47 +689,29 @@ pub(crate) fn build_args_classified_with_mode(
for path in BASELINE_RO_BIND_PATHS {
args.extend(["--ro-bind-try".into(), (*path).into(), (*path).into()]);
}
args.extend([
"--symlink".into(),
VAR_RUN_COMPAT_TARGET.into(),
VAR_RUN_COMPAT_LINK.into(),
]);
args.extend(["--dev".into(), DEV_PATH.into()]);
args.extend(["--proc".into(), PROC_PATH.into()]);
args.extend(["--tmpfs".into(), TMP_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()]);
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
// always overrides a shallower ancestor with a different intent regardless
// of which policy list it came from (e.g. `readwritePaths: ["/data/secrets"]`
// must survive `deniedPaths: ["/data"]`). bwrap applies mounts in order and
// the last at a path wins, so walking the specificity-ordered list last —
// after the baseline + virtual filesystems above — gives the intended
// precedence. `resolve_mount_order` assumes object normalization already ran
// (it does, in the runner before `build_args`), so exact same-path conflicts
// are already collapsed to the strictest intent.
// Policy mounts are emitted shallow-to-deep so the most specific path wins.
for mount in wxc_common::filesystem_resolve::resolve_mount_order(&request.policy) {
match mount.intent {
// Read-write: override the base ro-bind and any standard mount.
FsIntent::ReadWrite => {
args.extend(["--bind".into(), mount.path.clone(), mount.path.clone()]);
args.extend(["--bind".into(), mount.path.clone(), mount.path]);
}
// Read-only: already covered by the base ro-bind, but listed
// explicitly so the intent is clear and it overrides any rw parent.
FsIntent::ReadOnly => {
args.extend(["--ro-bind".into(), mount.path.clone(), mount.path.clone()]);
args.extend(["--ro-bind".into(), mount.path.clone(), mount.path]);
}
FsIntent::Denied => {
if denied_files.contains(&mount.path) {
args.extend(["--ro-bind".into(), "/dev/null".into(), mount.path.clone()]);
args.extend(["--ro-bind".into(), "/dev/null".into(), mount.path]);
} else {
args.extend(["--tmpfs".into(), mount.path.clone()]);
args.extend(["--tmpfs".into(), mount.path]);
}
}
}
Expand Down
Loading
Loading