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
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 @@ -286,6 +286,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.

### Filesystem Policy

The `filesystem` section defines path access policy shared across backends:
Expand Down
11 changes: 7 additions & 4 deletions docs/version-specific-parser-migration-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ over newer configuration fixtures, primarily the Seatbelt validation corpus
from #1125. The rebase added 73 JSON documents and removed one, producing 67
additional equivalent accepts and five additional shared rejections without
changing the seven classified exact-stricter results.
The corpus baseline was refreshed again on 2026-09-15 for #1160, whose eleven
Bubblewrap cwd-preflight fixtures are equivalent accepts under both parsers.

This branch is a combined delivery: it makes exact contracts authoritative
and completes the v0.9 directional-network cutover. The latter is a breaking
Expand Down Expand Up @@ -57,15 +59,16 @@ changes cannot accidentally weaken the exact boundary. It also compares every
corpus document through the public loader and the exact parser oracle. After
the development-contract cutover moved three formerly convergent documents
into the explicit removal inventory, the retained rolling characterization now
converges across Windows and Linux at 331 equivalent accepts and 14 shared
converges across Windows and Linux at 354 equivalent accepts and 14 shared
rejections. The accept count dropped by one when the 0.9 removal of
`seatbelt.launchMethod` retired `tests/examples/27_mac_terminal_sandboxed.json`,
the only corpus document that exercised `launchMethod: "open"`, and rose by two
when the Seatbelt directional-ingress fixtures
`seatbelt_net_egress_deny_ingress_allow.json` and
`seatbelt_net_ingress_allow_loopback_deny.json` joined the corpus. Both parsers
accept each of those two and build identical runtime models, so they add no
shared rejection. Both retain no
shared rejection. #1160 adds eleven Bubblewrap cwd-preflight fixtures that both
parsers accept. Both retain no
exact-looser acceptance and no accepted-model mismatch. Assertion failures list
the shared-rejection files so future platform-specific movement is attributable
rather than represented only by aggregate counts.
Expand All @@ -81,8 +84,8 @@ producer-migration rebase:
- The Rust workspace passed 4,148 tests with 23 ignored.
- The Node SDK passed its build and 304 tests, with 19 skipped.
- The .NET SDK passed 118 tests, with 24 skipped.
- The config validator examined 349 documents: 341 validated successfully and
eight were confirmed as intentionally invalid exemptions.
- The config validator examined 358 documents: 347 validated successfully and
11 were confirmed as intentionally invalid exemptions.
- Schema-version, exact-contract codegen, SDK wire-type codegen, and package
version-sync gates passed.
- The seven residual fixtures at that checkpoint were exercised through the rebuilt
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 @@ -645,47 +655,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