grok-build-upstream-mirror/crates/codegen/xai-grok-sandbox/src/deny/mod.rs
grokkybara[bot] c68e39f604 Publish harness and TUI open-source
initial sync from the monorepo
2026-07-16 06:46:02 +01:00

309 lines
13 KiB
Rust

//! Kernel-enforced deny paths for sandbox profiles.
//!
//! macOS: Seatbelt platform rules via [`nono::CapabilitySet::add_platform_rule`].
//! Linux: Landlock cannot deny a subpath of an allowed tree; read-deny is
//! enforced via bwrap bind-over (see [`crate::bwrap_reexec_command`]).
#[cfg(all(feature = "enforce", unix))]
use nono::CapabilitySet;
#[cfg(all(feature = "enforce", unix))]
use std::path::{Path, PathBuf};
// Glob deny entries (detection, macOS regex translation, Linux launch-time
// expansion) live in a submodule; re-exported so call sites use `deny::…`.
#[cfg(all(feature = "enforce", unix))]
mod glob;
#[cfg(all(feature = "enforce", target_os = "linux"))]
pub(crate) use glob::{
DENY_GLOB_MAX_DEPTH, DENY_GLOB_MAX_ENTRIES, DENY_GLOB_MAX_MATCHES, expand_deny_globs,
};
#[cfg(all(feature = "enforce", unix))]
pub(crate) use glob::{apply_deny_globs_to_capability_set, partition_deny_entries};
/// Escape a path for use inside a Seatbelt `(literal "...")` / `(subpath "...")`
/// filter (used for both forms, hence the generic name).
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn escape_seatbelt_path(path: &Path) -> Option<String> {
let s = path.to_str()?;
// Reject all control chars (matching nono's escape_path); silently passing
// one through would target a different path than intended.
if s.chars().any(|c| c.is_control()) {
return None;
}
Some(s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// All literal paths a deny rule must cover on macOS: the as-given path, its
/// canonical form, and the `/private` firmlink alias of each (e.g. `/tmp/x` <->
/// `/private/tmp/x`) so a deny cannot be bypassed via an alias.
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn macos_deny_aliases(path: &Path, canonical: &Path) -> Vec<PathBuf> {
let mut forms: Vec<PathBuf> = vec![path.to_path_buf()];
if canonical != path {
forms.push(canonical.to_path_buf());
}
for form in forms.clone() {
if let Some(alias) = toggle_private_prefix(&form)
&& !forms.contains(&alias)
{
forms.push(alias);
}
}
forms
}
/// Toggle the macOS `/private` firmlink prefix for `/tmp`, `/var`, `/etc`
/// (e.g. `/private/tmp/x` <-> `/tmp/x`). Returns `None` for unaffected paths.
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn toggle_private_prefix(path: &Path) -> Option<PathBuf> {
let s = path.to_str()?;
for dir in ["tmp", "var", "etc"] {
if let Some(rest) = s.strip_prefix(&format!("/private/{dir}"))
&& (rest.is_empty() || rest.starts_with('/'))
{
return Some(PathBuf::from(format!("/{dir}{rest}")));
}
if let Some(rest) = s.strip_prefix(&format!("/{dir}"))
&& (rest.is_empty() || rest.starts_with('/'))
{
return Some(PathBuf::from(format!("/private/{dir}{rest}")));
}
}
None
}
/// Specific Seatbelt write sub-actions denied for a denied path.
///
/// `(deny file-write* ...)` alone does NOT win: nono emits platform rules
/// between the read-allows and the write-allows, so the broad workspace
/// `(allow file-write* (subpath <ws>))` is emitted AFTER our deny and wins by
/// last-match — leaving an in-workspace denied path writable (so `mv x y && cat y`
/// could relocate and read it). Empirically, denying each concrete write
/// sub-action (every one more specific than the `file-write*` grant) makes the
/// deny win regardless of emission order, fully blocking overwrite AND relocation
/// (rename/unlink). This is observed per-operation rule-list behavior, not a
/// guaranteed action-specificity rule — the macOS e2e is the contract.
#[cfg(all(feature = "enforce", target_os = "macos"))]
const SEATBELT_WRITE_DENY_ACTIONS: &[&str] = &[
"file-write-data",
"file-write-create",
"file-write-unlink",
"file-write-mode",
"file-write-owner",
"file-write-flags",
"file-write-times",
"file-write-setugid",
];
/// Emit the full read+write deny rule set for a single Seatbelt `filter`
/// (`(literal ...)` or `(subpath ...)`). See [`SEATBELT_WRITE_DENY_ACTIONS`] for
/// why the specific write sub-actions are required in addition to `file-write*`.
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn emit_seatbelt_deny(caps: &mut CapabilitySet, filter: &str) -> anyhow::Result<()> {
// Read-deny wins via last-match (platform rules are emitted after read-allows).
caps.add_platform_rule(format!("(deny file-read* {filter})"))?;
// Catch-all write-deny (wins for out-of-workspace paths with no competing
// write grant, e.g. ~/.ssh) ...
caps.add_platform_rule(format!("(deny file-write* {filter})"))?;
// ... plus action-specific write denies that also win inside the workspace.
for action in SEATBELT_WRITE_DENY_ACTIONS {
caps.add_platform_rule(format!("(deny {action} {filter})"))?;
}
Ok(())
}
/// Apply kernel-level deny rules for the given paths.
///
/// On macOS, adds Seatbelt read-deny + write-deny (incl. specific write
/// sub-actions) rules. On Linux, this is a no-op — callers must use bwrap
/// bind-over for read-deny.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn apply_deny_paths_to_capability_set(
caps: &mut CapabilitySet,
deny_paths: &[PathBuf],
) -> anyhow::Result<()> {
if deny_paths.is_empty() {
return Ok(());
}
#[cfg(target_os = "macos")]
{
// Every literal path a deny rule was emitted for, so explicit file caps
// colliding with a denied path can be removed for all alias forms too.
let mut rule_paths: Vec<PathBuf> = Vec::new();
for path in deny_paths {
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.clone());
// Dir-ness (subpath vs literal) is decided by existence and applies
// to all alias forms of this path.
let use_subpath = deny_path_is_dir(&canonical);
// The base profile grants `(allow file-read* (subpath "/"))`, which
// matches every *literal* path. macOS reaches /tmp, /var, /etc via
// symlinks into /private, so denying only the canonical form is
// bypassable through the alias — emit a deny for each alias form.
for form in macos_deny_aliases(path, &canonical) {
let Some(escaped) = escape_seatbelt_path(&form) else {
// Fail CLOSED: a deny path we can't express as a Seatbelt
// filter would otherwise be silently unprotected while the
// sandbox still reports active. Erroring leaves apply() not
// applied so the shell's macOS `!is_applied` guard refuses to
// start — matching Linux's any-bind-fails-closed.
anyhow::bail!("cannot escape deny path {form:?} for Seatbelt");
};
// `literal` for files, `subpath` for dirs, so deny rules are more
// specific than parent-directory allows.
let filter = if use_subpath {
format!("(subpath \"{escaped}\")")
} else {
format!("(literal \"{escaped}\")")
};
emit_seatbelt_deny(caps, &filter)?;
rule_paths.push(form);
}
}
let _removed = caps.remove_exact_file_caps_for_paths(&rule_paths);
tracing::info!(
count = deny_paths.len(),
"Applied Seatbelt deny rules for sandbox deny paths"
);
}
#[cfg(target_os = "linux")]
{
let _ = caps;
tracing::debug!(
count = deny_paths.len(),
"Linux deny paths require bwrap bind-over (applied at process re-exec)"
);
}
Ok(())
}
/// Resolve deny path strings from a profile against the workspace.
///
/// Relative paths are joined with `workspace`. Absolute paths are used as-is.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn resolve_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec<PathBuf> {
deny.iter()
.map(|p| {
if p.is_absolute() {
p.clone()
} else {
workspace.join(p)
}
})
.collect()
}
/// Resolve, sort, and dedup a profile's deny list into the canonical set of
/// paths to enforce. Shared by the Seatbelt (profiles.rs) and bwrap (lib.rs) sites.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn effective_deny_paths(workspace: &Path, deny: &[PathBuf]) -> Vec<PathBuf> {
let mut paths = resolve_deny_paths(workspace, deny);
paths.sort();
paths.dedup();
paths
}
/// Resolve already-partitioned EXACT (non-glob) deny entries into bwrap bind
/// strings: resolved against `workspace`, sorted, deduped, stringified. The
/// caller passes the exact slice from `partition_deny_entries`. A Linux bwrap
/// concern (macOS denies via Seatbelt, not path strings) — the exact-path
/// parallel to glob's `expand_deny_globs`, so both deny resolutions live in `deny/`.
#[cfg(all(feature = "enforce", target_os = "linux"))]
pub(crate) fn exact_deny_path_strings(workspace: &Path, exact: &[PathBuf]) -> Vec<String> {
effective_deny_paths(workspace, exact)
.into_iter()
.map(|p| p.display().to_string())
.collect()
}
/// Whether a deny path should be treated as a directory (Seatbelt `subpath` /
/// bwrap dir-bind) rather than a single file: true for existing directories,
/// false otherwise. Shared by the macOS and Linux deny sites so the two cannot
/// silently diverge.
///
/// Limitation: a non-existent deny path is treated as a single file (macOS emits
/// `(literal …)`); if it is later created as a directory its children are not
/// covered on macOS. Name concrete existing paths to deny a whole directory tree.
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn deny_path_is_dir(canonical: &Path) -> bool {
canonical.is_dir()
}
#[cfg(test)]
mod tests {
// All tests here exercise enforce+unix paths; without the gate `super::*`
// is unused on `--no-default-features`.
#[cfg(all(feature = "enforce", unix))]
use super::*;
#[test]
#[cfg(all(feature = "enforce", unix))]
fn resolve_deny_paths_relative() {
let ws = PathBuf::from("/tmp/project");
let deny = vec![PathBuf::from(".env"), PathBuf::from("/etc/shadow")];
let resolved = resolve_deny_paths(&ws, &deny);
assert_eq!(resolved[0], PathBuf::from("/tmp/project/.env"));
assert_eq!(resolved[1], PathBuf::from("/etc/shadow"));
}
#[test]
#[cfg(all(feature = "enforce", target_os = "linux"))]
fn exact_deny_path_strings_resolves_sorts_dedups() {
let ws = PathBuf::from("/ws");
// Already-partitioned exact entries (relative + absolute), with a duplicate.
let exact = vec![
PathBuf::from("src/server.pem"),
PathBuf::from(".env"),
PathBuf::from("/etc/shadow"),
PathBuf::from(".env"),
];
let paths = exact_deny_path_strings(&ws, &exact);
assert!(paths.iter().any(|p| p == "/ws/.env"), "{paths:?}");
assert!(paths.iter().any(|p| p == "/ws/src/server.pem"), "{paths:?}");
assert!(paths.iter().any(|p| p == "/etc/shadow"), "{paths:?}");
// Sorted + deduped (the duplicate `.env` collapses to one).
let mut sorted = paths.clone();
sorted.sort();
sorted.dedup();
assert_eq!(paths, sorted, "must be sorted and deduped: {paths:?}");
assert!(
!paths.iter().any(|p| p.contains('*')),
"no globs: {paths:?}"
);
}
#[test]
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn seatbelt_escape_handles_quotes() {
let p = Path::new("/tmp/foo\"bar");
let escaped = escape_seatbelt_path(p).unwrap();
assert!(escaped.contains("\\\""));
}
#[test]
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn seatbelt_escape_rejects_control_chars() {
assert!(escape_seatbelt_path(Path::new("/tmp/a\u{07}b")).is_none());
}
#[test]
#[cfg(all(feature = "enforce", target_os = "macos"))]
fn macos_deny_aliases_cover_private_symlink() {
// A canonical /private/tmp denied path must also be denied via its /tmp alias,
// otherwise the broad read-allow leaves it readable through the alias.
let canonical = Path::new("/private/tmp/proj/.env");
let aliases = macos_deny_aliases(canonical, canonical);
assert!(
aliases.iter().any(|p| p == Path::new("/tmp/proj/.env")),
"expected /tmp alias in {aliases:?}"
);
assert_eq!(
toggle_private_prefix(Path::new("/tmp/proj/.env")),
Some(PathBuf::from("/private/tmp/proj/.env"))
);
// Non-firmlink paths (e.g. home credential dirs) have no alias.
assert_eq!(toggle_private_prefix(Path::new("/Users/x/.ssh")), None);
}
}