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
32 changes: 16 additions & 16 deletions docs/backends/seatbelt/seatbelt-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,18 +115,23 @@ given path. `deniedPaths` sits outside that ordering and always outranks.

Before a path reaches the profile, MXC:

1. **Expands `~`** against `$HOME`.
2. **Collapses** `//`, `/./`, and trailing `/`. Rejects `..` (see above).
3. **Resolves symlinked roots** β€” `/etc`, `/tmp`, `/var` β†’ `/private/…`;
`/home` β†’ `/System/Volumes/Data/home`.
1. **Expands `~`** against `$HOME` and resolves relative paths from MXC's
working directory.
2. **Resolves symlinks**, including files, directory ancestors, chains, and
macOS roots such as `/tmp` β†’ `/private/tmp`.
3. **Applies `..` after following symlinks** and collapses redundant separators
and `.` segments. Missing trailing components are supported.
4. **Re-applies precedence** (`denied` > `readonly` > `readwrite`) to the
*resolved* paths.

Steps 3 and 4 aren't cosmetic. Those roots are symlinks, and the kernel fully
resolves a path before matching it β€” a rule written against the unresolved path
is silently dead. Step 4 catches two spellings of the same path
(`readonlyPaths: ["/private/tmp/x"]` vs `readwritePaths: ["/tmp/x"]`) that the
shared parser can't see are identical.
For example, if `/work/link` points to `/data/nested`,
`/work/link/../secret.txt` applies to `/data/secret.txt`, not
`/work/secret.txt`. A deny on a symlink protects its real target and other
symlink aliases, even under a broader read-write grant.

Paths are resolved before launch. Dangling symlinks, symlink loops, inaccessible
ancestors, and targets whose resolved names cannot be represented as UTF-8 are
rejected rather than producing rules that might not protect the intended path.

`/Users` needs no rewriting β€” it's a firmlink, not a symlink.

Expand Down Expand Up @@ -584,7 +589,6 @@ once you can see the rules that were emitted.
| Command not found, or a tool can't find its libraries | The environment is always cleared and `PATH` resets to `/usr/bin:/bin:/usr/sbin:/sbin` | Add `process.env`, and `readonlyPaths` for the install prefix (e.g. `/opt/homebrew`) |
| A client is configured with `HTTP_PROXY` but reaches nothing | It's ignoring the proxy vars. Outbound is kernel-scoped to the proxy port, so it can't connect anywhere else | Use a proxy-aware client β€” see [Proxy support](#proxy-support-what-is-and-isnt-enforced) |
| A test runner or build tool fails spawning workers | `nestedPty: false`, or its IPC socket sits under a `readonlyPaths`/`deniedPaths` entry | Leave `nestedPty` at `true`; put socket directories in `readwritePaths` |
| The same config runs on Linux but is rejected on macOS | A `..` segment in a path β€” Seatbelt-only rejection | Pass the fully resolved path |

### What gets rejected

Expand Down Expand Up @@ -614,12 +618,8 @@ rejected before Seatbelt validation.

| Config | Why it's rejected | Do this instead |
|---|---|---|
| Any path containing a `..` segment | macOS resolves `..` *after* following symlinks, so a lexically-resolved rule can silently point elsewhere (`/tmp/..` is `/private`, not `/`) | Pass the fully resolved path |

This one is **Seatbelt-only** β€” the shared parser accepts `..`, so a
cross-backend policy using it will run on Linux and fail on macOS. That's
deliberate: the alternative is a rule that matches nothing, which for
`deniedPaths` would fail *open*.
| A dangling symlink, symlink loop, or inaccessible path | The target cannot be resolved reliably for a Seatbelt path filter | Repair the link or make the path accessible to MXC |
| A path resolving to a non-UTF-8 name | The profile cannot represent the target faithfully | Use a target with a UTF-8 name |

#### Streaming / GUI

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,14 @@ fn resolve_denied_paths(
let mut files = HashSet::new();
for p in &policy.denied_paths {
if let Some(resolved) = resolve_through_symlinks(Path::new(p)) {
let resolved = resolved.to_str().ok_or_else(|| {
if let Some(error) = resolved.error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider stopping .. popping in resolve_through_symlinks once a symlink fails to resolve, or skipping the mask in resolve_denied_paths when resolved.error is set. Right now, a denied path like /work/dangling/../secret pops the broken link and masks the unrelated path /work/secret.

logger.log_line(&format!(
"Bubblewrap: deniedPaths entry '{p}' could not be fully resolved ({error}); \
masking '{}' instead.",
resolved.path.display()
));
}
let resolved = resolved.path.to_str().ok_or_else(|| {
format!(
"Bubblewrap: deniedPaths entry '{p}' resolves to a non-UTF-8 host path that \
cannot be safely masked; refusing to start."
Expand Down
15 changes: 9 additions & 6 deletions src/mxc-sdk/src/backends/bubblewrap/common/proxy_network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1675,10 +1675,14 @@ fn is_nameserver_line(line: &str) -> bool {
fn resolver_target_of(start: &Path) -> Result<PathBuf, String> {
let resolved = resolve_through_symlinks(start)
.ok_or_else(|| format!("{} is not a resolvable path", start.display()))?;
if let Some(error) = resolved.error {
return Err(format!(
"{} could not be resolved ({error})",
start.display()
));
Comment thread
Copilot marked this conversation as resolved.
}
let resolved = resolved.path;

// A chain that loops or dangles leaves canonicalization unfinished, so the
// walk hands back path text that is still a link. Binding there aborts the
// sandbox, which is worse than the broken DNS being fixed.
let metadata = fs::symlink_metadata(&resolved)
.map_err(|error| format!("{} could not be read ({error})", resolved.display()))?;
if !metadata.file_type().is_file() {
Expand Down Expand Up @@ -3483,8 +3487,7 @@ mod tests {
assert!(resolver_target_of(&dangling).is_err());
}

/// A link loop leaves canonicalization unfinished, so the walk hands back a
/// path that is still a link; binding there would abort the sandbox.
/// Reject a link loop before attempting to mount the resolver file.
#[test]
fn the_resolver_target_gives_up_on_a_link_loop() {
let dir = tempfile::tempdir().unwrap();
Expand All @@ -3495,7 +3498,7 @@ mod tests {
std::os::unix::fs::symlink(&first, &second).unwrap();

let error = resolver_target_of(&first).expect_err("a loop has no target");
assert!(error.contains("regular file"), "got: {error}");
assert!(error.contains("could not be resolved"), "got: {error}");
}

/// A directory would be mounted over as if it were the resolver file.
Expand Down
8 changes: 5 additions & 3 deletions src/mxc-sdk/src/backends/seatbelt/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

//! `seatbelt_common` β€” shared library for the macOS sandbox backend.
//!
//! - [`profile_builder`] is platform-agnostic (pure string generation) so it
//! compiles and is fully unit-tested on every host. This lets reviewers
//! validate profile generation without a Mac.
//! - [`profile_builder`] formats prepared inputs without host discovery, so it
//! is unit-tested on every host.
//! - macOS-only preparation resolves paths and discovers host directories.
//! - [`seatbelt_policy`] owns the backend's network invariants and effective-
//! policy helpers. Also platform-agnostic, so the rules are unit-tested
//! everywhere even though only macOS can execute them.
Expand All @@ -18,5 +18,7 @@ pub mod default_env;
pub mod profile_builder;
pub mod seatbelt_policy;

#[cfg(target_os = "macos")]
pub(crate) mod profile_preparation;
#[cfg(target_os = "macos")]
pub mod seatbelt_runner;
Loading
Loading