Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
64
crates/codegen/xai-grok-sandbox/Cargo.toml
Normal file
64
crates/codegen/xai-grok-sandbox/Cargo.toml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-sandbox"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "OS-level sandboxing for Grok Build using kernel primitives (Landlock/Seatbelt) via nono"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
dunce = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
xai-grok-config = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
# Pinned exact: the macOS Seatbelt deny precedence (see deny.rs `emit_seatbelt_deny`)
|
||||
# depends on nono's observed rule-emission order. A bump can silently change it and
|
||||
# re-open the `mv x y && cat y` bypass with `is_applied()` still true, so re-verify
|
||||
# `deny_paths_e2e` on real macOS (it self-skips in CI) before bumping.
|
||||
#
|
||||
# Non-optional (not gated on `enforce`): Cargo has no target-conditional features
|
||||
# table, so a `dep:nono` reference from the cross-platform `enforce` feature would
|
||||
# break feature resolution on non-unix targets (e.g. Windows builds). nono/globset
|
||||
# are unix-only here, so they only compile on unix anyway; the enforce *code*
|
||||
# stays gated on `cfg(all(feature = "enforce", unix))`.
|
||||
nono = { version = "=0.53.0", default-features = false }
|
||||
# globset validates every deny glob on BOTH platforms (so a glob is interpreted
|
||||
# identically or rejected identically) and matches them on Linux. Non-optional for
|
||||
# the same reason as nono above.
|
||||
globset = { workspace = true }
|
||||
|
||||
# Linux additionally walks the tree to expand globs to existing paths (enforce+
|
||||
# linux only). cfg(linux)-gated rather than enforce-gated: the cross-platform
|
||||
# enforce feature can't reference a linux-only optional dep without breaking macOS
|
||||
# feature resolution, so a `--no-default-features` Linux build pulls it unused.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ignore = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["enforce"]
|
||||
## Enable kernel-enforced sandboxing via nono (Landlock/Seatbelt). Marker feature
|
||||
## only: the backing deps (nono/globset) are non-optional unix-only deps (see
|
||||
## above), and the enforce code is gated on `cfg(all(feature = "enforce", unix))`.
|
||||
## On non-unix targets enabling this feature is a no-op (no kernel sandbox exists).
|
||||
enforce = []
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = { workspace = true }
|
||||
# Compiles the hand-rolled macOS deny-glob regex in tests to assert it matches
|
||||
# EXACTLY the set globset (the Linux backend) matches — the cross-platform parity
|
||||
# guard for the glob dialect.
|
||||
regex = { workspace = true }
|
||||
|
||||
# The smoke-test example exercises kernel enforcement (uses enforce-only APIs
|
||||
# like `support_info`), so it only builds with the `enforce` feature. This keeps
|
||||
# `--no-default-features --all-targets` clean.
|
||||
[[example]]
|
||||
name = "sandbox_smoke_test"
|
||||
required-features = ["enforce"]
|
||||
169
crates/codegen/xai-grok-sandbox/examples/sandbox_smoke_test.rs
Normal file
169
crates/codegen/xai-grok-sandbox/examples/sandbox_smoke_test.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! Smoke test for sandbox enforcement.
|
||||
//!
|
||||
//! This binary applies a sandbox profile and then attempts various operations
|
||||
//! to verify kernel enforcement. Run it directly to test:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Test workspace profile (should allow writes to CWD, block ~/Desktop)
|
||||
//! cargo run -p xai-grok-sandbox --example sandbox_smoke_test
|
||||
//!
|
||||
//! # Test strict profile
|
||||
//! cargo run -p xai-grok-sandbox --example sandbox_smoke_test -- strict
|
||||
//!
|
||||
//! # Test read-only profile
|
||||
//! cargo run -p xai-grok-sandbox --example sandbox_smoke_test -- read-only
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use xai_grok_sandbox::{ProfileName, SandboxManager};
|
||||
|
||||
fn main() {
|
||||
// Parse profile from args (default: workspace).
|
||||
let profile_name = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "workspace".to_string());
|
||||
|
||||
let profile: ProfileName = profile_name.parse().unwrap_or_else(|e| {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Check platform support before applying
|
||||
let support = SandboxManager::support_info();
|
||||
println!(
|
||||
"Platform support: {}",
|
||||
if support.is_supported { "YES" } else { "NO" }
|
||||
);
|
||||
println!("Details: {}", support.details);
|
||||
|
||||
if !support.is_supported {
|
||||
println!("\n⚠️ Sandbox not supported on this platform.");
|
||||
println!(" On macOS: Seatbelt should be available (10.5+)");
|
||||
println!(" On Linux: Landlock requires kernel ≥ 5.13");
|
||||
println!("\n Tests will show what WOULD happen, but won't enforce.");
|
||||
}
|
||||
|
||||
let workspace = std::env::current_dir().expect("failed to get cwd");
|
||||
println!("\nProfile: {profile}");
|
||||
println!("Workspace: {}", workspace.display());
|
||||
|
||||
// Apply the sandbox
|
||||
println!("\n--- Applying sandbox ---");
|
||||
let mut sandbox = SandboxManager::new(profile, &workspace);
|
||||
match sandbox.apply(&workspace) {
|
||||
Ok(()) => {
|
||||
if sandbox.is_applied() {
|
||||
println!("✅ Sandbox applied (kernel-enforced, irreversible)");
|
||||
} else {
|
||||
println!("⚠️ Sandbox was not applied (unsupported platform or Off profile)");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Sandbox apply failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"Child network restricted: {}",
|
||||
sandbox.restrict_child_network()
|
||||
);
|
||||
|
||||
// Test operations
|
||||
println!("\n--- Testing filesystem operations ---\n");
|
||||
|
||||
// Test 1: Read CWD (should always work)
|
||||
test_read("Read CWD", &workspace);
|
||||
|
||||
// Test 2: Read /tmp (should work for workspace/read-only)
|
||||
test_read("Read /tmp", Path::new("/tmp"));
|
||||
|
||||
// Test 3: Read home directory (should work for workspace/read-only, blocked for strict)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
test_read("Read ~/", &home);
|
||||
}
|
||||
|
||||
// Test 4: Write to CWD (should work for workspace/strict, blocked for read-only)
|
||||
let test_file = workspace.join(".sandbox-test-write");
|
||||
test_write("Write to CWD", &test_file);
|
||||
// Clean up
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
|
||||
// Test 5: Write to /tmp (should work for workspace/strict, blocked for read-only)
|
||||
let tmp_test = Path::new("/tmp/.grok-sandbox-test");
|
||||
test_write("Write to /tmp", tmp_test);
|
||||
let _ = std::fs::remove_file(tmp_test);
|
||||
|
||||
// Test 6: Write outside workspace (should be blocked for all active profiles)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let outside = home.join(".sandbox-test-blocked");
|
||||
test_write("Write to ~/", &outside);
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
}
|
||||
|
||||
// Test 7: Read ~/.ssh (a custom profile's `deny` list could block this)
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let ssh = home.join(".ssh");
|
||||
if ssh.exists() {
|
||||
test_read("Read ~/.ssh/", &ssh);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
println!("\n--- Sandbox event log ---");
|
||||
let events = sandbox.logger().take_events();
|
||||
for event in &events {
|
||||
println!(
|
||||
" {:?}: {} {:?}",
|
||||
event.event_type, event.profile, event.target
|
||||
);
|
||||
}
|
||||
if events.is_empty() {
|
||||
println!(" (no events recorded)");
|
||||
}
|
||||
|
||||
println!("\n✅ Smoke test complete");
|
||||
}
|
||||
|
||||
fn test_read(label: &str, path: &Path) {
|
||||
if path.is_file() {
|
||||
match std::fs::read(path) {
|
||||
Ok(_) => println!(" ✅ {label}: OK (read)"),
|
||||
Err(e)
|
||||
if e.raw_os_error() == Some(libc::EACCES)
|
||||
|| e.raw_os_error() == Some(libc::EPERM) =>
|
||||
{
|
||||
println!(" 🔒 {label}: BLOCKED ({e})");
|
||||
}
|
||||
Err(e) => println!(" ❌ {label}: ERROR ({e})"),
|
||||
}
|
||||
return;
|
||||
}
|
||||
match std::fs::read_dir(path) {
|
||||
Ok(mut entries) => {
|
||||
let count = entries.by_ref().take(5).count();
|
||||
println!(" ✅ {label}: OK ({count} entries)");
|
||||
}
|
||||
Err(e) => {
|
||||
if e.raw_os_error() == Some(libc::EACCES) || e.raw_os_error() == Some(libc::EPERM) {
|
||||
println!(" 🔒 {label}: BLOCKED ({e})");
|
||||
} else {
|
||||
println!(" ❌ {label}: ERROR ({e})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test_write(label: &str, path: &Path) {
|
||||
match std::fs::write(path, b"sandbox-test") {
|
||||
Ok(()) => {
|
||||
println!(" ✅ {label}: OK (written)");
|
||||
}
|
||||
Err(e) => {
|
||||
if e.raw_os_error() == Some(libc::EACCES) || e.raw_os_error() == Some(libc::EPERM) {
|
||||
println!(" 🔒 {label}: BLOCKED ({e})");
|
||||
} else {
|
||||
println!(" ❌ {label}: ERROR ({e})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
crates/codegen/xai-grok-sandbox/src/child_net.rs
Normal file
111
crates/codegen/xai-grok-sandbox/src/child_net.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
//! Per-child seccomp network filter. No-op on non-Linux.
|
||||
|
||||
/// Install seccomp BPF filter blocking network syscalls.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Must be called in a `pre_exec` context (after `fork`, before `exec`).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
use libc::{
|
||||
BPF_ABS, BPF_JEQ, BPF_JMP, BPF_K, BPF_LD, BPF_RET, BPF_W, PR_SET_NO_NEW_PRIVS,
|
||||
PR_SET_SECCOMP, SECCOMP_MODE_FILTER, SYS_accept, SYS_accept4, SYS_bind, SYS_connect,
|
||||
SYS_listen, SYS_sendmsg, SYS_sendto, prctl, sock_filter, sock_fprog,
|
||||
};
|
||||
|
||||
const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000;
|
||||
const SECCOMP_RET_ERRNO: u32 = 0x0005_0000;
|
||||
const EPERM_VAL: u32 = 1; // libc::EPERM
|
||||
|
||||
macro_rules! bpf_stmt {
|
||||
($code:expr, $k:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: 0,
|
||||
jf: 0,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! bpf_jump {
|
||||
($code:expr, $k:expr, $jt:expr, $jf:expr) => {
|
||||
sock_filter {
|
||||
code: $code as u16,
|
||||
jt: $jt,
|
||||
jf: $jf,
|
||||
k: $k as u32,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const NR_OFFSET: u32 = 0; // seccomp_data.nr offset
|
||||
|
||||
let blocked_syscalls: &[i64] = &[
|
||||
SYS_connect,
|
||||
SYS_bind,
|
||||
SYS_sendto,
|
||||
SYS_sendmsg,
|
||||
SYS_listen,
|
||||
SYS_accept,
|
||||
SYS_accept4,
|
||||
];
|
||||
|
||||
let mut filter: Vec<sock_filter> = Vec::new();
|
||||
let total_checks = blocked_syscalls.len();
|
||||
|
||||
// 1. Load syscall number
|
||||
filter.push(bpf_stmt!(BPF_LD | BPF_W | BPF_ABS, NR_OFFSET));
|
||||
|
||||
// 2. Check each blocked syscall
|
||||
for (i, &syscall) in blocked_syscalls.iter().enumerate() {
|
||||
let remaining = total_checks - i - 1;
|
||||
filter.push(bpf_jump!(
|
||||
BPF_JMP | BPF_JEQ | BPF_K,
|
||||
syscall,
|
||||
remaining as u8 + 1, // match: jump to ERRNO
|
||||
0 // no match: check next
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Default: ALLOW
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ALLOW));
|
||||
|
||||
// 4. Blocked: ERRNO(EPERM)
|
||||
filter.push(bpf_stmt!(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM_VAL));
|
||||
|
||||
let prog = sock_fprog {
|
||||
len: filter.len() as u16,
|
||||
filter: filter.as_mut_ptr(),
|
||||
};
|
||||
|
||||
// Must set PR_SET_NO_NEW_PRIVS before applying seccomp filter
|
||||
// SAFETY: prctl with PR_SET_NO_NEW_PRIVS is safe in pre_exec context.
|
||||
if unsafe { prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// SAFETY: prog is a valid sock_fprog pointing to our filter array.
|
||||
if unsafe {
|
||||
prctl(
|
||||
PR_SET_SECCOMP,
|
||||
SECCOMP_MODE_FILTER as libc::c_ulong,
|
||||
&prog as *const _ as libc::c_ulong,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Safety
|
||||
///
|
||||
/// No-op on non-Linux.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub unsafe fn install_child_network_filter() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
843
crates/codegen/xai-grok-sandbox/src/deny/glob.rs
Normal file
843
crates/codegen/xai-grok-sandbox/src/deny/glob.rs
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
//! Glob deny entries: detection, the macOS Seatbelt-regex translation, and the
|
||||
//! Linux launch-time expansion. A deny entry is a GLOB iff it contains a glob
|
||||
//! metacharacter; macOS emits an anchored runtime regex (covers files created
|
||||
//! after launch), Linux expands to concrete existing matches at bwrap launch
|
||||
//! (best-effort).
|
||||
//!
|
||||
//! Parity invariant: `validate_deny_glob` accepts/rejects identically on both
|
||||
//! platforms, and the accepted subset translates the SAME on both — asserted by
|
||||
//! the `macos_regex_matches_globset_property` cross-product test.
|
||||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use nono::CapabilitySet;
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use std::path::{Path, PathBuf};
|
||||
// macOS regex translation reuses the parent module's alias + write-deny helpers.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
use super::{emit_seatbelt_deny, macos_deny_aliases};
|
||||
|
||||
/// Whether a raw deny entry is a glob pattern rather than an exact path. True iff
|
||||
/// it contains a gitignore-style metacharacter (`*`, `?`, `[`).
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn is_glob(entry: &str) -> bool {
|
||||
entry.contains(['*', '?', '['])
|
||||
}
|
||||
|
||||
/// Split a profile's raw deny entries into exact paths (handled by the literal /
|
||||
/// subpath kernel-deny flow) and glob patterns. Non-glob entries are returned
|
||||
/// unchanged so their exact-path enforcement is preserved with no regression.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn partition_deny_entries(deny: &[PathBuf]) -> (Vec<PathBuf>, Vec<String>) {
|
||||
let mut exact = Vec::new();
|
||||
let mut globs = Vec::new();
|
||||
for entry in deny {
|
||||
match entry.to_str() {
|
||||
Some(s) if is_glob(s) => globs.push(s.to_string()),
|
||||
_ => exact.push(entry.clone()),
|
||||
}
|
||||
}
|
||||
(exact, globs)
|
||||
}
|
||||
|
||||
/// Split a glob into its literal root directory and the glob tail (from the first
|
||||
/// component containing a metacharacter onward). Relative globs root at
|
||||
/// `workspace` (recursive `**` allowed); absolute globs root at their leading
|
||||
/// non-glob components (e.g. `/home/**/.ssh` -> root `/home`, tail `**/.ssh`).
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn split_glob_root(workspace: &Path, glob: &str) -> (PathBuf, String) {
|
||||
let Some(abs) = glob.strip_prefix('/') else {
|
||||
return (workspace.to_path_buf(), glob.to_string());
|
||||
};
|
||||
let mut root = PathBuf::from("/");
|
||||
let mut tail: Vec<&str> = Vec::new();
|
||||
let mut in_tail = false;
|
||||
for comp in abs.split('/') {
|
||||
if in_tail {
|
||||
tail.push(comp);
|
||||
} else if is_glob(comp) {
|
||||
in_tail = true;
|
||||
tail.push(comp);
|
||||
} else if !comp.is_empty() {
|
||||
root.push(comp);
|
||||
}
|
||||
}
|
||||
(root, tail.join("/"))
|
||||
}
|
||||
|
||||
/// Validate a deny glob on BOTH platforms so a given pattern is interpreted
|
||||
/// IDENTICALLY everywhere or rejected everywhere (never silently under-enforced
|
||||
/// on macOS). Two checks, run before the macOS regex translation and the Linux
|
||||
/// globset expansion alike:
|
||||
///
|
||||
/// 1. Reject `{`/`}`/`\`: globset honors brace alternation and backslash-escapes,
|
||||
/// but Seatbelt's runtime regex (sourced from globset's own `.regex()` mis-
|
||||
/// enforces `**/` for root-level paths, so we hand-roll the regex instead and
|
||||
/// cannot faithfully reproduce those forms — rejecting them on both platforms
|
||||
/// keeps the two backends in agreement. A user wanting alternation writes
|
||||
/// separate deny entries.
|
||||
/// 2. Compile through `globset` (the Linux matcher) so a malformed glob (`a**b`,
|
||||
/// unterminated `[`) fails closed identically on both platforms.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn validate_deny_glob(glob: &str) -> anyhow::Result<()> {
|
||||
if let Some(c) = glob.chars().find(|&c| matches!(c, '{' | '}' | '\\')) {
|
||||
anyhow::bail!(
|
||||
"deny glob {glob:?} uses unsupported metacharacter '{c}' \
|
||||
(brace alternation and backslash-escapes are not supported; \
|
||||
use separate deny entries)"
|
||||
);
|
||||
}
|
||||
// `**` must be a whole path component (gitignore semantics). A non-component
|
||||
// `**` (e.g. `a**b`) would translate to `.*` on macOS but collapse to `*` in
|
||||
// globset — reject it on both platforms so they never diverge.
|
||||
for comp in glob.split('/') {
|
||||
if comp.contains("**") && comp != "**" {
|
||||
anyhow::bail!(
|
||||
"deny glob {glob:?}: `**` must be its own path component (got segment {comp:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Char classes: support only the simple subset that translates identically to
|
||||
// globset. Reject a literal `]`-first member (`[]a]`) and any nested `[` —
|
||||
// which covers POSIX `[[:…:]]` — since globset and the hand-rolled regex parse
|
||||
// those differently. (A leading `!`/`^` negation IS supported.)
|
||||
let cc: Vec<char> = glob.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < cc.len() {
|
||||
if cc[i] != '[' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
if matches!(cc.get(j), Some('!') | Some('^')) {
|
||||
j += 1;
|
||||
}
|
||||
if cc.get(j) == Some(&']') {
|
||||
anyhow::bail!("deny glob {glob:?}: a literal ']' as first class member is unsupported");
|
||||
}
|
||||
while j < cc.len() && cc[j] != ']' {
|
||||
if cc[j] == '[' {
|
||||
anyhow::bail!(
|
||||
"deny glob {glob:?}: nested '[' / POSIX '[[:…:]]' classes are unsupported"
|
||||
);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// Unterminated class: let the globset build below report it uniformly.
|
||||
i = if j < cc.len() { j + 1 } else { cc.len() };
|
||||
}
|
||||
globset::GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("invalid deny glob {glob:?}: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Push `c` as a regex literal, escaping it when it is a regex metacharacter.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn push_escaped_regex_literal(out: &mut String, c: char) {
|
||||
if matches!(
|
||||
c,
|
||||
'.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\'
|
||||
) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
|
||||
/// Regex-escape every character of a literal path segment.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn escape_regex_literal_str(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
push_escaped_regex_literal(&mut out, c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Translate a gitignore-style glob tail into an (unanchored) Seatbelt regex
|
||||
/// body. Dialect: `**/`->`(.*/)?`, `**`->`.*`, `*`->`[^/]*`, `?`->`[^/]`,
|
||||
/// `[...]` classes copied with a leading `!`/`^` -> regex negation `[^…]`, all
|
||||
/// other literal text regex-escaped. Only the class subset `validate_deny_glob`
|
||||
/// accepts reaches here, so it always matches globset.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_tail_to_regex(tail: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = tail.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'*' => {
|
||||
if chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
if chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
out.push_str("(.*/)?"); // `**/` spans zero or more dirs
|
||||
} else {
|
||||
out.push_str(".*"); // `**` spans anything, incl. `/`
|
||||
}
|
||||
} else {
|
||||
out.push_str("[^/]*"); // `*` stops at a path separator
|
||||
}
|
||||
}
|
||||
'?' => out.push_str("[^/]"),
|
||||
'[' => {
|
||||
out.push('[');
|
||||
// globset treats a leading `!` OR `^` as negation -> regex `[^…]`
|
||||
// (validate_deny_glob has rejected the class forms that would drift).
|
||||
if matches!(chars.peek(), Some('!') | Some('^')) {
|
||||
chars.next();
|
||||
out.push('^');
|
||||
}
|
||||
while let Some(cc) = chars.next() {
|
||||
if cc == ']' {
|
||||
break;
|
||||
}
|
||||
// Backslash-escapes are rejected by validate_deny_glob; this
|
||||
// passthrough stays defensive.
|
||||
if cc == '\\' {
|
||||
out.push('\\');
|
||||
if let Some(n) = chars.next() {
|
||||
out.push(n);
|
||||
}
|
||||
} else {
|
||||
out.push(cc);
|
||||
}
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
c => push_escaped_regex_literal(&mut out, c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Anchored Seatbelt regex bodies for one glob — one per macOS alias form of the
|
||||
/// glob's root (workspace for relative, literal prefix for absolute) so the broad
|
||||
/// read-allow cannot be bypassed via the `/private` firmlink alias.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_to_seatbelt_regexes(workspace: &Path, glob: &str) -> Vec<String> {
|
||||
let (root, tail) = split_glob_root(workspace, glob);
|
||||
let tail_regex = glob_tail_to_regex(&tail);
|
||||
let canonical_root = dunce::canonicalize(&root).unwrap_or_else(|_| root.clone());
|
||||
let mut regexes = Vec::new();
|
||||
for form in macos_deny_aliases(&root, &canonical_root) {
|
||||
let Some(form_str) = form.to_str() else {
|
||||
continue;
|
||||
};
|
||||
let escaped_root = escape_regex_literal_str(form_str);
|
||||
// Avoid a double slash when the root is `/`.
|
||||
let sep = if escaped_root.ends_with('/') { "" } else { "/" };
|
||||
regexes.push(format!("^{escaped_root}{sep}{tail_regex}$"));
|
||||
}
|
||||
regexes
|
||||
}
|
||||
|
||||
/// Wrap a finished regex body in a Seatbelt `(regex #"…")` filter, escaping the
|
||||
/// SBPL string delimiter and rejecting control chars. Fail-closed: returns
|
||||
/// `None` for an inexpressible pattern so the caller errors rather than emitting
|
||||
/// a rule that silently targets the wrong path.
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn seatbelt_regex_filter(regex: &str) -> Option<String> {
|
||||
if regex.chars().any(|c| c.is_control()) {
|
||||
return None;
|
||||
}
|
||||
let escaped = regex.replace('"', "\\\"");
|
||||
Some(format!("(regex #\"{escaped}\")"))
|
||||
}
|
||||
|
||||
/// Apply kernel-level deny rules for glob patterns.
|
||||
///
|
||||
/// On macOS, translate each glob to an anchored Seatbelt regex and emit the same
|
||||
/// read + per-write-sub-action denies as the exact-path flow (so `mv x y && cat y`
|
||||
/// stays closed), covering files created after launch. On Linux this is a no-op:
|
||||
/// a mount namespace can't match a regex at runtime, so globs are expanded to
|
||||
/// concrete paths and bound over at bwrap re-exec (see [`expand_deny_globs`]).
|
||||
///
|
||||
/// Unlike the exact-path flow, this does NOT call `remove_exact_file_caps_for_paths`
|
||||
/// (a glob can't enumerate the file caps it collides with); glob denies rely on
|
||||
/// Seatbelt last-match ordering — the deny platform rules are emitted after the
|
||||
/// read/write allows, so the regex deny wins. The e2e is the contract.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn apply_deny_globs_to_capability_set(
|
||||
caps: &mut CapabilitySet,
|
||||
workspace: &Path,
|
||||
globs: &[String],
|
||||
) -> anyhow::Result<()> {
|
||||
if globs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
for glob in globs {
|
||||
// Fail CLOSED on any glob that isn't expressible identically on both
|
||||
// platforms (braces/backslash) or is malformed — same check Linux runs.
|
||||
validate_deny_glob(glob)?;
|
||||
let regexes = glob_to_seatbelt_regexes(workspace, glob);
|
||||
if regexes.is_empty() {
|
||||
// Fail CLOSED: a glob we can't anchor would be silently
|
||||
// unprotected while the sandbox still reports active.
|
||||
anyhow::bail!("cannot translate deny glob {glob:?} to a Seatbelt regex");
|
||||
}
|
||||
for regex in regexes {
|
||||
let Some(filter) = seatbelt_regex_filter(®ex) else {
|
||||
anyhow::bail!("cannot express deny glob {glob:?} as a Seatbelt regex filter");
|
||||
};
|
||||
emit_seatbelt_deny(caps, &filter)?;
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
count = globs.len(),
|
||||
"Applied Seatbelt deny regexes for sandbox deny globs"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = (caps, workspace);
|
||||
tracing::debug!(
|
||||
count = globs.len(),
|
||||
"Linux deny globs are expanded and bound over at bwrap re-exec (launch-time)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Caps for launch-time deny-glob expansion on Linux. A mount namespace can't
|
||||
/// glob at runtime, so globs are expanded to existing matches once at launch;
|
||||
/// these bounds stop a broad glob (e.g. `**/*`) from exploding the bind list or
|
||||
/// taking an unbounded walk. Exceeding either fails closed (see [`expand_deny_globs`]).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
pub(crate) const DENY_GLOB_MAX_DEPTH: usize = 64;
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
pub(crate) const DENY_GLOB_MAX_MATCHES: usize = 4096;
|
||||
/// Total tree entries the walk may visit before failing closed. Bounds launch
|
||||
/// latency on large repos (`max_matches` caps matches, not entries visited) so a
|
||||
/// broad glob that matches little still can't walk an unbounded tree each launch.
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
pub(crate) const DENY_GLOB_MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
/// Classify a walk error hit while expanding deny globs. A permission error means
|
||||
/// the same-uid agent is equally denied by the kernel, so skipping that subtree
|
||||
/// exposes nothing; any other error (transient IO, fd exhaustion, a race) could
|
||||
/// hide a readable match, so it is fatal and we fail closed rather than under-enforce.
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn deny_glob_walk_error_is_fatal(err: &ignore::Error) -> bool {
|
||||
match err.io_error() {
|
||||
Some(io) => io.kind() != std::io::ErrorKind::PermissionDenied,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand deny GLOBS into the concrete EXISTING paths that match, for the Linux
|
||||
/// bwrap bind-over. Relative globs anchor at `workspace`; absolute globs at their
|
||||
/// literal (non-glob) prefix. The walk DISABLES gitignore/hidden filters (a
|
||||
/// denied secret like `.env` or `*.pem` is usually both) and never follows
|
||||
/// symlinks (a symlink must not smuggle its target into the deny set).
|
||||
///
|
||||
/// Returns `None` (fail closed) if a glob is invalid, the walk is truncated by
|
||||
/// `max_depth`, more than `max_entries` are visited, matches exceed `max_matches`,
|
||||
/// or the walk hits a non-permission error — so the caller refuses to start rather
|
||||
/// than under-enforcing or exploding the bind list. A permission error is skipped
|
||||
/// (the same-uid agent is equally OS-denied). Each fail-closed cause is logged so
|
||||
/// the refusal names the glob (not the generic "install bubblewrap" path).
|
||||
///
|
||||
/// Best-effort: files created AFTER launch that match a glob are NOT covered on
|
||||
/// Linux. macOS Seatbelt enforces the same globs as runtime regexes, so they are.
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
pub(crate) fn expand_deny_globs(
|
||||
workspace: &Path,
|
||||
globs: &[String],
|
||||
max_depth: usize,
|
||||
max_matches: usize,
|
||||
max_entries: usize,
|
||||
) -> Option<Vec<String>> {
|
||||
use globset::{GlobBuilder, GlobSetBuilder};
|
||||
use ignore::WalkBuilder;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Log + surface the real reason before fail-closing so the shell's refusal is
|
||||
// not misattributed to a missing bubblewrap.
|
||||
let fail = |reason: String| -> Option<Vec<String>> {
|
||||
tracing::error!(%reason, "sandbox deny-glob expansion failed; refusing to start");
|
||||
eprintln!("error: sandbox deny glob could not be enforced on Linux: {reason}");
|
||||
None
|
||||
};
|
||||
|
||||
let ws = workspace.to_string_lossy().into_owned();
|
||||
let mut builder = GlobSetBuilder::new();
|
||||
let mut roots: BTreeSet<PathBuf> = BTreeSet::new();
|
||||
for glob in globs {
|
||||
// Same validation macOS runs, so a malformed/unsupported glob fails closed
|
||||
// identically on both platforms.
|
||||
if let Err(e) = validate_deny_glob(glob) {
|
||||
return fail(e.to_string());
|
||||
}
|
||||
// Match against absolute paths: relative globs get the (escaped) workspace
|
||||
// prefix; absolute globs are used as-is. `literal_separator(true)` =>
|
||||
// `*`/`?` stop at `/` (gitignore-style), matching the macOS translation.
|
||||
let pattern = if glob.starts_with('/') {
|
||||
glob.clone()
|
||||
} else {
|
||||
format!("{}/{}", globset::escape(&ws), glob)
|
||||
};
|
||||
let Ok(compiled) = GlobBuilder::new(&pattern).literal_separator(true).build() else {
|
||||
return fail(format!("could not compile glob {glob:?}"));
|
||||
};
|
||||
builder.add(compiled);
|
||||
roots.insert(split_glob_root(workspace, glob).0);
|
||||
}
|
||||
let Ok(set) = builder.build() else {
|
||||
return fail("could not build glob set".to_string());
|
||||
};
|
||||
|
||||
let mut matches: BTreeSet<String> = BTreeSet::new();
|
||||
let mut visited: usize = 0;
|
||||
for root in roots {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
let walker = WalkBuilder::new(&root)
|
||||
.max_depth(Some(max_depth))
|
||||
.standard_filters(false)
|
||||
.hidden(false)
|
||||
.follow_links(false)
|
||||
.build();
|
||||
for dent in walker {
|
||||
let dent = match dent {
|
||||
Ok(dent) => dent,
|
||||
Err(e) => {
|
||||
// Hidden subtree: skip OS-enforced permission errors, fail closed on anything else.
|
||||
if deny_glob_walk_error_is_fatal(&e) {
|
||||
return fail(format!("walk error under a deny-glob root: {e}"));
|
||||
}
|
||||
tracing::warn!(error = %e, "skipping unreadable entry during deny-glob walk");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
visited += 1;
|
||||
if visited > max_entries {
|
||||
return fail(format!(
|
||||
"walk visited over {max_entries} entries (glob too broad)"
|
||||
));
|
||||
}
|
||||
// A directory at the depth cap may hide deeper matches we cannot see;
|
||||
// fail closed rather than silently under-enforce.
|
||||
if dent.depth() >= max_depth && dent.file_type().is_some_and(|ft| ft.is_dir()) {
|
||||
return fail(format!("tree deeper than the {max_depth}-level depth cap"));
|
||||
}
|
||||
let path = dent.path();
|
||||
if set.is_match(path) {
|
||||
// A non-UTF8 match can't be bound by a string path. Fail closed
|
||||
// (like the exact-path Seatbelt flow) rather than skip it and leave
|
||||
// a matching secret readable while the sandbox reports active.
|
||||
let Some(s) = path.to_str() else {
|
||||
return fail(format!("deny-glob match has a non-UTF8 path: {path:?}"));
|
||||
};
|
||||
matches.insert(s.to_owned());
|
||||
if matches.len() > max_matches {
|
||||
return fail(format!("matched over {max_matches} files (glob too broad)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(matches.into_iter().collect())
|
||||
}
|
||||
|
||||
#[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 is_glob_detects_metacharacters() {
|
||||
assert!(is_glob("**/.env"));
|
||||
assert!(is_glob("**/*.pem"));
|
||||
assert!(is_glob("secrets/**"));
|
||||
assert!(is_glob("a?b"));
|
||||
assert!(is_glob("[abc].txt"));
|
||||
// Exact paths must NOT be treated as globs (no regression in literal deny).
|
||||
assert!(!is_glob(".env"));
|
||||
assert!(!is_glob("src/server.pem"));
|
||||
assert!(!is_glob("/etc/shadow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn partition_separates_globs_from_exact_paths() {
|
||||
let deny = vec![
|
||||
PathBuf::from(".env"),
|
||||
PathBuf::from("**/*.pem"),
|
||||
PathBuf::from("/etc/shadow"),
|
||||
PathBuf::from("secrets/**"),
|
||||
];
|
||||
let (exact, globs) = partition_deny_entries(&deny);
|
||||
assert_eq!(
|
||||
exact,
|
||||
vec![PathBuf::from(".env"), PathBuf::from("/etc/shadow")]
|
||||
);
|
||||
assert_eq!(
|
||||
globs,
|
||||
vec!["**/*.pem".to_string(), "secrets/**".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn split_glob_root_relative_vs_absolute() {
|
||||
let ws = Path::new("/ws");
|
||||
assert_eq!(
|
||||
split_glob_root(ws, "**/.env"),
|
||||
(PathBuf::from("/ws"), "**/.env".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
split_glob_root(ws, "/home/**/.ssh"),
|
||||
(PathBuf::from("/home"), "**/.ssh".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_tail_translates_to_seatbelt_regex() {
|
||||
assert_eq!(glob_tail_to_regex("**/.env"), "(.*/)?\\.env");
|
||||
assert_eq!(glob_tail_to_regex("**/*.pem"), "(.*/)?[^/]*\\.pem");
|
||||
assert_eq!(glob_tail_to_regex("secrets/**"), "secrets/.*");
|
||||
assert_eq!(glob_tail_to_regex("*.key"), "[^/]*\\.key");
|
||||
assert_eq!(glob_tail_to_regex("a?b"), "a[^/]b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_tail_translates_char_classes() {
|
||||
assert_eq!(glob_tail_to_regex("[abc].txt"), "[abc]\\.txt");
|
||||
assert_eq!(glob_tail_to_regex("[a-z].rs"), "[a-z]\\.rs");
|
||||
// A leading `!` OR `^` is NEGATION in globset -> regex `[^…]` (both must
|
||||
// produce the SAME negated class, else macOS under-matches).
|
||||
assert_eq!(glob_tail_to_regex("[!a]b"), "[^a]b");
|
||||
assert_eq!(glob_tail_to_regex("[^a]b"), "[^a]b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn validate_deny_glob_accepts_subset_rejects_rest() {
|
||||
// Supported subset (`*`, `?`, `**`, `[...]` incl. `[!a]`/`[^a]` negation).
|
||||
for g in [
|
||||
"**/*.pem",
|
||||
"**/.env",
|
||||
"secrets/**",
|
||||
"[abc].txt",
|
||||
"[a-z].rs",
|
||||
"[!a]b",
|
||||
"[^a]b",
|
||||
"a?b",
|
||||
"/home/**/.ssh",
|
||||
] {
|
||||
assert!(validate_deny_glob(g).is_ok(), "{g} should be supported");
|
||||
}
|
||||
// Braces + backslash drift macOS vs globset -> rejected (fail closed) on BOTH.
|
||||
for g in ["**/*.{pem,key}", "a\\*b", "{a,b}"] {
|
||||
assert!(validate_deny_glob(g).is_err(), "{g} should be rejected");
|
||||
}
|
||||
// Char-class forms that parse differently in globset vs the regex engine.
|
||||
for g in ["[]a]", "[[:alpha:]]", "[a[b]"] {
|
||||
assert!(
|
||||
validate_deny_glob(g).is_err(),
|
||||
"{g} unsupported char class should be rejected"
|
||||
);
|
||||
}
|
||||
// Malformed globs fail closed identically to the Linux globset backend.
|
||||
for g in ["a**b", "**a", "[abc"] {
|
||||
assert!(
|
||||
validate_deny_glob(g).is_err(),
|
||||
"{g} should be rejected as malformed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_to_regex_doubles_private_aliased_root() {
|
||||
// A workspace under /tmp (firmlinked to /private/tmp) must emit a deny
|
||||
// regex for BOTH alias roots, else the broad read-allow leaks via the alias.
|
||||
let regexes = glob_to_seatbelt_regexes(Path::new("/tmp/projalias"), "**/.env");
|
||||
assert!(
|
||||
regexes.contains(&"^/tmp/projalias/(.*/)?\\.env$".to_string()),
|
||||
"{regexes:?}"
|
||||
);
|
||||
assert!(
|
||||
regexes.contains(&"^/private/tmp/projalias/(.*/)?\\.env$".to_string()),
|
||||
"{regexes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn macos_regex_matches_globset_property() {
|
||||
// PARITY GUARD (cross-product): for EVERY pattern `validate_deny_glob`
|
||||
// accepts, the hand-rolled macOS regex must match a path IFF globset (the
|
||||
// Linux backend) matches it. Generated from building blocks (literals,
|
||||
// regex-metachar literal, `*`, `?`, `**`, char classes incl. both
|
||||
// negations, ranges, class-content edge cases, and a `]` outside a class)
|
||||
// crossed with sample paths, so any future dialect drift fails
|
||||
// mechanically. Rejected forms are asserted to fail closed.
|
||||
let segs = [
|
||||
"a", "x.y", "*", "?", "**", "[abc]", "[a-z]", "[!a]", "[^a]", "[.]", "[*]", "[a^]",
|
||||
"[a-]", "[-a]", "*]",
|
||||
];
|
||||
let paths = [
|
||||
"a",
|
||||
"ab",
|
||||
"x",
|
||||
"x.y",
|
||||
"xay",
|
||||
"^",
|
||||
"!",
|
||||
".",
|
||||
"-",
|
||||
"*",
|
||||
"b",
|
||||
"a/b",
|
||||
"ab/cd",
|
||||
"sub/a",
|
||||
"sub/dir/a",
|
||||
".env",
|
||||
"sub/.env",
|
||||
"k.pem",
|
||||
"foo.env",
|
||||
".envrc",
|
||||
"secrets/x",
|
||||
"]",
|
||||
"a]",
|
||||
];
|
||||
// Build single- and two-segment patterns from the blocks.
|
||||
let mut patterns: Vec<String> = Vec::new();
|
||||
for a in segs {
|
||||
patterns.push(a.to_string());
|
||||
for b in segs {
|
||||
patterns.push(format!("{a}/{b}"));
|
||||
}
|
||||
}
|
||||
for p in &patterns {
|
||||
if validate_deny_glob(p).is_err() {
|
||||
continue; // rejected patterns aren't enforced on either platform
|
||||
}
|
||||
let regexes = glob_to_seatbelt_regexes(Path::new("/ws"), p);
|
||||
assert_eq!(regexes.len(), 1, "expected one regex for {p:?}");
|
||||
let re = regex::Regex::new(®exes[0]).unwrap_or_else(|e| panic!("{p:?}: {e}"));
|
||||
let gs = globset::GlobBuilder::new(&format!("/ws/{p}"))
|
||||
.literal_separator(true)
|
||||
.build()
|
||||
.unwrap()
|
||||
.compile_matcher();
|
||||
for path in paths {
|
||||
let abs = format!("/ws/{path}");
|
||||
assert_eq!(
|
||||
re.is_match(&abs),
|
||||
gs.is_match(&abs),
|
||||
"DRIFT for pattern {p:?} on {abs:?}: macos={}, globset={}",
|
||||
re.is_match(&abs),
|
||||
gs.is_match(&abs)
|
||||
);
|
||||
}
|
||||
}
|
||||
// Forms that MUST fail closed on both platforms (cannot translate identically).
|
||||
for bad in [
|
||||
"[]a]",
|
||||
"[[:alpha:]]",
|
||||
"{a,b}",
|
||||
"**/*.{pem,key}",
|
||||
"a**b",
|
||||
"**a",
|
||||
"[abc",
|
||||
] {
|
||||
assert!(validate_deny_glob(bad).is_err(), "{bad} must be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_to_regex_anchors_relative_at_workspace() {
|
||||
// A non-existent workspace cannot canonicalize/alias, so exactly one
|
||||
// anchored regex is produced.
|
||||
let regexes = glob_to_seatbelt_regexes(Path::new("/ws-does-not-exist-xyz"), "**/*.pem");
|
||||
assert_eq!(
|
||||
regexes,
|
||||
vec!["^/ws-does-not-exist-xyz/(.*/)?[^/]*\\.pem$".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn glob_to_regex_roots_absolute_at_literal_prefix() {
|
||||
let regexes =
|
||||
glob_to_seatbelt_regexes(Path::new("/ws-does-not-exist-xyz"), "/nope-xyz/**/.ssh");
|
||||
assert_eq!(regexes, vec!["^/nope-xyz/(.*/)?\\.ssh$".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn seatbelt_regex_filter_wraps_and_rejects_control_chars() {
|
||||
assert_eq!(
|
||||
seatbelt_regex_filter("^/ws/(.*/)?\\.env$").unwrap(),
|
||||
"(regex #\"^/ws/(.*/)?\\.env$\")"
|
||||
);
|
||||
assert!(seatbelt_regex_filter("a\u{07}b").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", target_os = "macos"))]
|
||||
fn apply_deny_globs_emits_nono_accepted_rules() {
|
||||
// Every emitted `(deny … (regex …))` must pass nono's validate_platform_rule.
|
||||
let mut caps = CapabilitySet::new();
|
||||
let globs = vec![
|
||||
"**/*.pem".to_string(),
|
||||
"**/.env".to_string(),
|
||||
"secrets/**".to_string(),
|
||||
];
|
||||
apply_deny_globs_to_capability_set(&mut caps, Path::new("/ws-does-not-exist-xyz"), &globs)
|
||||
.expect("emitted Seatbelt deny regexes must be accepted by nono");
|
||||
}
|
||||
|
||||
// Linux launch-time expansion + its fail-closed caps. Gated to enforce+linux,
|
||||
// so they run on the Linux CI lane (not on macOS).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
mod linux_expand {
|
||||
use super::*;
|
||||
|
||||
struct TmpTree(PathBuf);
|
||||
impl Drop for TmpTree {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn tmp_tree(tag: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"deny-glob-ut-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_nested_pem_and_dotenv_excludes_control() {
|
||||
let ws = tmp_tree("match");
|
||||
let _g = TmpTree(ws.clone());
|
||||
std::fs::create_dir_all(ws.join("sub/dir")).unwrap();
|
||||
std::fs::write(ws.join("sub/dir/key.pem"), "x").unwrap();
|
||||
std::fs::write(ws.join(".env"), "x").unwrap(); // hidden + usually gitignored
|
||||
std::fs::write(ws.join("readable.txt"), "x").unwrap();
|
||||
let globs = vec!["**/*.pem".to_string(), "**/.env".to_string()];
|
||||
let out = expand_deny_globs(&ws, &globs, 64, 4096, 200_000).expect("should expand");
|
||||
assert!(
|
||||
out.iter().any(|p| p.ends_with("sub/dir/key.pem")),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(out.iter().any(|p| p.ends_with("/.env")), "{out:?}");
|
||||
assert!(!out.iter().any(|p| p.ends_with("readable.txt")), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_when_nothing_matches() {
|
||||
let ws = tmp_tree("empty");
|
||||
let _g = TmpTree(ws.clone());
|
||||
std::fs::write(ws.join("a.txt"), "x").unwrap();
|
||||
let out = expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).unwrap();
|
||||
assert!(out.is_empty(), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_match_cap() {
|
||||
let ws = tmp_tree("matchcap");
|
||||
let _g = TmpTree(ws.clone());
|
||||
for i in 0..5 {
|
||||
std::fs::write(ws.join(format!("k{i}.pem")), "x").unwrap();
|
||||
}
|
||||
assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 2, 200_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_depth_cap() {
|
||||
let ws = tmp_tree("depthcap");
|
||||
let _g = TmpTree(ws.clone());
|
||||
std::fs::create_dir_all(ws.join("a/b/c")).unwrap();
|
||||
std::fs::write(ws.join("a/b/c/k.pem"), "x").unwrap();
|
||||
// A directory sits at the depth cap -> deeper matches could hide -> None.
|
||||
assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 1, 4096, 200_000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_entries_cap() {
|
||||
let ws = tmp_tree("entriescap");
|
||||
let _g = TmpTree(ws.clone());
|
||||
for i in 0..10 {
|
||||
std::fs::write(ws.join(format!("f{i}.txt")), "x").unwrap();
|
||||
}
|
||||
// Broad walk, nothing matches, but the entries cap still fails closed.
|
||||
assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 3).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_glob() {
|
||||
let ws = tmp_tree("reject");
|
||||
let _g = TmpTree(ws.clone());
|
||||
// Braces are rejected identically to macOS (fail closed).
|
||||
assert!(
|
||||
expand_deny_globs(&ws, &["**/*.{pem,key}".to_string()], 64, 4096, 200_000)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_descend_symlinked_dir() {
|
||||
let ws = tmp_tree("symlink");
|
||||
let _g = TmpTree(ws.clone());
|
||||
let outside = tmp_tree("symlink-outside");
|
||||
let _g2 = TmpTree(outside.clone());
|
||||
std::fs::write(outside.join("secret.pem"), "x").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, ws.join("link")).unwrap();
|
||||
// follow_links(false): the symlink must not smuggle its target in.
|
||||
let out = expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).unwrap();
|
||||
assert!(
|
||||
!out.iter().any(|p| p.contains("secret.pem")),
|
||||
"symlinked dir must not be descended: {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_error_permission_skipped_others_fatal() {
|
||||
use std::io;
|
||||
// EACCES on a dir: the same-uid agent is equally OS-denied -> skip (non-fatal).
|
||||
let perm = ignore::Error::from(io::Error::from(io::ErrorKind::PermissionDenied));
|
||||
assert!(!deny_glob_walk_error_is_fatal(&perm));
|
||||
// A non-permission IO error could hide a readable match -> fatal (fail closed).
|
||||
let other = ignore::Error::from(io::Error::other("boom"));
|
||||
assert!(deny_glob_walk_error_is_fatal(&other));
|
||||
// A non-IO walk error (e.g. a symlink loop) has no io_error -> fatal.
|
||||
let loop_err = ignore::Error::Loop {
|
||||
ancestor: PathBuf::from("/a"),
|
||||
child: PathBuf::from("/a/b"),
|
||||
};
|
||||
assert!(deny_glob_walk_error_is_fatal(&loop_err));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_closed_on_non_utf8_match() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let ws = tmp_tree("nonutf8");
|
||||
let _g = TmpTree(ws.clone());
|
||||
// A filename with an invalid UTF-8 byte that still matches `*.pem`.
|
||||
let name = std::ffi::OsStr::from_bytes(b"secret\xFF.pem");
|
||||
std::fs::write(ws.join(name), "x").unwrap();
|
||||
// The match can't be expressed as a UTF-8 bind path -> fail closed (None).
|
||||
assert!(expand_deny_globs(&ws, &["**/*.pem".to_string()], 64, 4096, 200_000).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
309
crates/codegen/xai-grok-sandbox/src/deny/mod.rs
Normal file
309
crates/codegen/xai-grok-sandbox/src/deny/mod.rs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
//! 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);
|
||||
}
|
||||
}
|
||||
733
crates/codegen/xai-grok-sandbox/src/lib.rs
Normal file
733
crates/codegen/xai-grok-sandbox/src/lib.rs
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
#![allow(
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_mut,
|
||||
unreachable_code,
|
||||
dead_code
|
||||
)]
|
||||
//! OS-level sandboxing for Grok Build via [nono](https://crates.io/crates/nono).
|
||||
//!
|
||||
//! Applied once at process startup. Covers in-process `tokio::fs` calls
|
||||
//! and child processes. Network is left open at the process level (agent
|
||||
//! needs LLM API); child network is blocked per-subprocess via seccomp.
|
||||
//!
|
||||
//! The `enforce` feature (on by default) pulls in `nono` for
|
||||
//! kernel-enforced sandboxing (Landlock/Seatbelt). When disabled, the
|
||||
//! crate still provides lightweight helpers (`log_violation`,
|
||||
//! `should_restrict_child_network`, `child_net`) that compile on all
|
||||
//! targets including musl.
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use xai_grok_sandbox::{SandboxManager, ProfileName};
|
||||
//! use std::path::Path;
|
||||
//!
|
||||
//! let workspace = Path::new("/home/user/project");
|
||||
//! let mut sandbox = SandboxManager::new(ProfileName::Workspace, workspace);
|
||||
//! sandbox.apply(workspace).expect("sandbox apply failed");
|
||||
//! sandbox.install();
|
||||
//! ```
|
||||
pub mod child_net;
|
||||
mod deny;
|
||||
mod logging;
|
||||
mod paths;
|
||||
mod profiles;
|
||||
mod types;
|
||||
pub use logging::SandboxLogger;
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use nono::Sandbox;
|
||||
pub use profiles::{
|
||||
ProfileName, SandboxConfig, SandboxProfile, load_sandbox_config, sandbox_profile_conflicts,
|
||||
};
|
||||
use std::path::Path;
|
||||
#[cfg(any(target_os = "linux", all(feature = "enforce", test)))]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub use types::{SandboxEvent, SandboxEventType, SandboxMetrics};
|
||||
static SANDBOX: OnceLock<GlobalSandboxState> = OnceLock::new();
|
||||
static CONFIGURED_PROFILE: OnceLock<String> = OnceLock::new();
|
||||
static RESTRICT_CHILD_NETWORK: AtomicBool = AtomicBool::new(false);
|
||||
static AUTO_ALLOW_BASH: AtomicBool = AtomicBool::new(false);
|
||||
const BWRAP_ENV_VAR: &str = "__GROK_INSIDE_BWRAP";
|
||||
pub fn is_inside_bwrap() -> bool {
|
||||
std::env::var(BWRAP_ENV_VAR).is_ok()
|
||||
}
|
||||
pub fn trust_bwrap_marker_for_devbox() -> bool {
|
||||
false
|
||||
}
|
||||
struct GlobalSandboxState {
|
||||
profile: String,
|
||||
logger: SandboxLogger,
|
||||
applied: bool,
|
||||
}
|
||||
/// Whether child subprocesses should have network blocked via seccomp.
|
||||
pub fn should_restrict_child_network() -> bool {
|
||||
RESTRICT_CHILD_NETWORK.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Whether bash commands should be auto-approved when the sandbox is active.
|
||||
pub fn should_auto_allow_bash() -> bool {
|
||||
AUTO_ALLOW_BASH.load(Ordering::Relaxed) && is_active()
|
||||
}
|
||||
pub fn set_auto_allow_bash(enabled: bool) {
|
||||
AUTO_ALLOW_BASH.store(enabled, Ordering::Relaxed);
|
||||
}
|
||||
/// Record the resolved sandbox profile at process startup (including `"off"`).
|
||||
pub fn set_configured_profile(name: impl Into<String>) {
|
||||
let _ = CONFIGURED_PROFILE.set(name.into());
|
||||
}
|
||||
/// Resolved sandbox profile from startup, or `None` if `set_configured_profile` was never called.
|
||||
pub fn configured_profile_name() -> Option<&'static str> {
|
||||
CONFIGURED_PROFILE.get().map(|s| s.as_str())
|
||||
}
|
||||
/// Whether the sandbox was successfully applied to this process.
|
||||
pub fn is_active() -> bool {
|
||||
SANDBOX.get().is_some_and(|s| s.applied)
|
||||
}
|
||||
/// The active sandbox profile name, or `None` if sandbox is not applied.
|
||||
pub fn profile_name() -> Option<&'static str> {
|
||||
SANDBOX
|
||||
.get()
|
||||
.filter(|s| s.applied)
|
||||
.map(|s| s.profile.as_str())
|
||||
}
|
||||
/// Log a sandbox violation. Immediately flushed to disk.
|
||||
/// No-op if sandbox is not active.
|
||||
pub fn log_violation(target: &str, operation: &str) {
|
||||
if let Some(state) = SANDBOX.get() {
|
||||
state.logger.log(SandboxEvent::fs_violation(
|
||||
&state.profile,
|
||||
target,
|
||||
operation,
|
||||
));
|
||||
let _ = state.logger.flush_to_disk();
|
||||
}
|
||||
}
|
||||
/// Flush sandbox events to disk. No-op if not initialized.
|
||||
pub fn flush() {
|
||||
if let Some(state) = SANDBOX.get()
|
||||
&& let Err(e) = state.logger.flush_to_disk()
|
||||
{
|
||||
tracing::warn!(error = % e, "Failed to flush sandbox events to disk");
|
||||
}
|
||||
}
|
||||
/// Violation metrics, or `None` if sandbox is not active.
|
||||
pub fn metrics() -> Option<&'static SandboxMetrics> {
|
||||
SANDBOX.get().map(|s| s.logger.metrics())
|
||||
}
|
||||
/// Manages the OS-level sandbox. Call `apply()` then `install()`.
|
||||
pub struct SandboxManager {
|
||||
profile: ProfileName,
|
||||
logger: SandboxLogger,
|
||||
net_restricted: bool,
|
||||
applied: bool,
|
||||
}
|
||||
impl SandboxManager {
|
||||
/// Create a sandbox manager. Does not apply until `apply()` is called.
|
||||
pub fn new(profile: ProfileName, _workspace: &Path) -> Self {
|
||||
let net_restricted = profile.restricts_network();
|
||||
Self {
|
||||
profile,
|
||||
logger: SandboxLogger::new(),
|
||||
net_restricted,
|
||||
applied: false,
|
||||
}
|
||||
}
|
||||
/// Apply the sandbox to the current process. **Irreversible.**
|
||||
/// Degrades gracefully if the platform doesn't support it.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub fn apply(&mut self, workspace: &Path) -> anyhow::Result<()> {
|
||||
if self.profile == ProfileName::Off {
|
||||
tracing::info!("Sandbox disabled (profile: off)");
|
||||
return Ok(());
|
||||
}
|
||||
let support = Sandbox::support_info();
|
||||
if !support.is_supported {
|
||||
tracing::warn!(
|
||||
details = % support.details,
|
||||
"Sandbox not supported on this platform, continuing without sandbox"
|
||||
);
|
||||
self.logger.log(SandboxEvent::apply_failed(
|
||||
&self.profile.to_string(),
|
||||
workspace,
|
||||
&support.details,
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let caps = self
|
||||
.profile
|
||||
.to_capability_set_with_config(workspace, &config)?;
|
||||
let mut resolved = self.profile.resolve_profile(workspace, &config)?;
|
||||
resolved.deny = deny::effective_deny_paths(workspace, &resolved.deny);
|
||||
self.net_restricted = self.profile.restricts_network_resolved(&config);
|
||||
match Sandbox::apply(&caps) {
|
||||
Ok(_) => {
|
||||
self.applied = true;
|
||||
if self.net_restricted {
|
||||
RESTRICT_CHILD_NETWORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
self.logger.log(SandboxEvent::profile_applied(
|
||||
&self.profile.to_string(),
|
||||
workspace,
|
||||
&resolved,
|
||||
));
|
||||
tracing::info!(
|
||||
profile = % self.profile, workspace = % workspace.display(),
|
||||
restrict_network = self.net_restricted,
|
||||
"Sandbox applied (kernel-enforced, irreversible)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
profile = % self.profile, error = % e,
|
||||
"Sandbox could not be applied, continuing without sandbox"
|
||||
);
|
||||
self.logger.log(SandboxEvent::apply_failed(
|
||||
&self.profile.to_string(),
|
||||
workspace,
|
||||
&e,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Stub when `enforce` feature is disabled — sandbox is not applied.
|
||||
#[cfg(not(all(feature = "enforce", unix)))]
|
||||
pub fn apply(&mut self, _workspace: &Path) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
profile = % self.profile,
|
||||
"Sandbox enforcement unavailable (built without 'enforce' feature)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
/// Store globally for session-lifetime violation logging.
|
||||
pub fn install(self) {
|
||||
let _ = self.logger.flush_to_disk();
|
||||
let _ = SANDBOX.set(GlobalSandboxState {
|
||||
profile: self.profile.to_string(),
|
||||
logger: self.logger,
|
||||
applied: self.applied,
|
||||
});
|
||||
}
|
||||
/// Check whether the current platform supports sandboxing.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub fn support_info() -> nono::SupportInfo {
|
||||
Sandbox::support_info()
|
||||
}
|
||||
/// Whether the sandbox was successfully applied.
|
||||
pub fn is_applied(&self) -> bool {
|
||||
self.applied
|
||||
}
|
||||
/// Whether child subprocesses should have network blocked.
|
||||
pub fn restrict_child_network(&self) -> bool {
|
||||
self.applied && self.net_restricted
|
||||
}
|
||||
/// The active profile name.
|
||||
pub fn profile(&self) -> &ProfileName {
|
||||
&self.profile
|
||||
}
|
||||
/// Access the sandbox event logger (before `install()`).
|
||||
pub fn logger(&self) -> &SandboxLogger {
|
||||
&self.logger
|
||||
}
|
||||
}
|
||||
/// Build a bwrap command that re-execs the current process with
|
||||
/// `deny_write` paths mounted read-only and `deny_read` paths bound
|
||||
/// over with an unreadable placeholder (EPERM on read).
|
||||
///
|
||||
/// Returns `None` if already inside bwrap. Caller should `cmd.exec()` the result.
|
||||
pub fn bwrap_reexec_command(
|
||||
deny_write: &[&str],
|
||||
deny_read: &[&str],
|
||||
) -> Option<std::process::Command> {
|
||||
if is_inside_bwrap() {
|
||||
return None;
|
||||
}
|
||||
let self_exe = std::env::current_exe().ok()?;
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut cmd = std::process::Command::new("bwrap");
|
||||
cmd.arg("--bind").arg("/").arg("/");
|
||||
for path in deny_write {
|
||||
if Path::new(path).exists() {
|
||||
cmd.arg("--ro-bind").arg(path).arg(path);
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if !deny_read.is_empty() {
|
||||
for path in deny_read {
|
||||
let Some(blocked) = bwrap_blocked_source_for_path(Path::new(path)) else {
|
||||
eprintln!(
|
||||
"error: could not create bwrap placeholder for read-deny path {path}; \
|
||||
refusing to start with a partial sandbox"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
cmd.arg("--ro-bind").arg(&blocked).arg(path);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = deny_read;
|
||||
cmd.arg("--dev-bind").arg("/dev").arg("/dev");
|
||||
cmd.arg("--proc").arg("/proc");
|
||||
cmd.env(BWRAP_ENV_VAR, "1");
|
||||
cmd.arg("--").arg(self_exe).args(args);
|
||||
Some(cmd)
|
||||
}
|
||||
/// Choose file vs directory placeholder for a deny path (existing dirs need a dir bind).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_blocked_source_for_path(path: &Path) -> Option<PathBuf> {
|
||||
if deny::deny_path_is_dir(path) {
|
||||
bwrap_blocked_placeholder("sandbox-blocked-dir", true)
|
||||
} else {
|
||||
bwrap_blocked_placeholder("sandbox-blocked", false)
|
||||
}
|
||||
}
|
||||
/// Without kernel enforcement there are no read-deny placeholders to bind over.
|
||||
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
||||
fn bwrap_blocked_source_for_path(_path: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
/// chmod a placeholder to mode 000 so a bwrap bind-over yields EPERM on read.
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn chmod_000(path: &Path) -> Option<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path).ok()?.permissions();
|
||||
perms.set_mode(0o000);
|
||||
std::fs::set_permissions(path, perms).ok()?;
|
||||
Some(())
|
||||
}
|
||||
/// Zero-permission placeholder (file or dir) under `grok_home` used by bwrap bind-over.
|
||||
///
|
||||
/// The placeholder name is suffixed with the current PID so concurrent grok
|
||||
/// processes don't race each other's create/remove/chmod on a shared path (which
|
||||
/// could yield `None` and the silent dropped-bind fail-open this avoids).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_blocked_placeholder(name: &str, want_dir: bool) -> Option<PathBuf> {
|
||||
use std::fs::OpenOptions;
|
||||
let path = paths::grok_home().join(format!("{name}.{}", std::process::id()));
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok()?;
|
||||
}
|
||||
if path.exists() {
|
||||
if path.is_dir() == want_dir {
|
||||
chmod_000(&path)?;
|
||||
return Some(path);
|
||||
}
|
||||
if path.is_dir() {
|
||||
std::fs::remove_dir_all(&path).ok()?;
|
||||
} else {
|
||||
std::fs::remove_file(&path).ok()?;
|
||||
}
|
||||
}
|
||||
if want_dir {
|
||||
std::fs::create_dir(&path).ok()?;
|
||||
} else {
|
||||
OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.ok()?;
|
||||
}
|
||||
chmod_000(&path)?;
|
||||
Some(path)
|
||||
}
|
||||
/// Whether a profile write-denies `/data` via the devbox bwrap bind (built-in
|
||||
/// `devbox` or a custom profile that `extends = "devbox"`). This is a pure mount,
|
||||
/// so it applies even WITHOUT the `enforce` feature.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_devbox_based(profile: &ProfileName, config: &SandboxConfig) -> bool {
|
||||
match profile {
|
||||
ProfileName::Devbox => true,
|
||||
ProfileName::Custom(name) => {
|
||||
config.profiles.get(name).and_then(|p| p.extends.as_deref()) == Some("devbox")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether kernel read-deny enforcement is required. The single source of truth
|
||||
/// for this classification so callers (e.g. the shell's fail-closed startup path)
|
||||
/// cannot drift and silently fail open.
|
||||
///
|
||||
/// Decided directly from the profile config (a `Custom` profile with a non-empty
|
||||
/// `deny`) — NOT from the resolved/expanded deny set, which returns empty on
|
||||
/// failure. Keying "requires" on that empty-on-error result would silently
|
||||
/// downgrade to fail-open (Linux) when resolution hiccups; this intrinsic check
|
||||
/// stays fail-closed.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub fn requires_read_deny(profile: &ProfileName, workspace: &Path) -> bool {
|
||||
match profile {
|
||||
ProfileName::Custom(name) => {
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
config
|
||||
.profiles
|
||||
.get(name)
|
||||
.is_some_and(|p| !p.deny.is_empty())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Stub when `enforce` is unavailable — nothing is kernel-enforced.
|
||||
#[cfg(not(all(feature = "enforce", unix)))]
|
||||
pub fn requires_read_deny(_profile: &ProfileName, _workspace: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
/// A profile's resolved bwrap deny plan: read-only mounts (`deny_write`),
|
||||
/// bound-over unreadable placeholders (`deny_read`), and whether the profile
|
||||
/// carries deny globs (`has_globs`, so the re-exec proceeds even with zero
|
||||
/// current matches — globs are best-effort on Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
struct BwrapDenyPlan {
|
||||
deny_write: Vec<String>,
|
||||
deny_read: Vec<String>,
|
||||
has_globs: bool,
|
||||
}
|
||||
/// Resolve a profile's full [`BwrapDenyPlan`] in ONE config read: the `/data`
|
||||
/// write-deny (devbox and devbox-extending customs), the exact read-deny paths,
|
||||
/// and the launch-time glob expansion. Returns `None` (fail closed) if a deny
|
||||
/// glob blows past the expansion caps or is invalid, so
|
||||
/// [`bwrap_reexec_for_profile`] refuses to start.
|
||||
///
|
||||
/// Best-effort on Linux: a mount namespace can't glob at runtime, so globs are
|
||||
/// expanded once here at launch — files matching them that are created LATER are
|
||||
/// NOT covered (macOS Seatbelt enforces the same globs as runtime regexes).
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
vec!["/data".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let entries = if *profile == ProfileName::Off {
|
||||
Vec::new()
|
||||
} else {
|
||||
profile
|
||||
.resolve_profile(workspace, &config)
|
||||
.map(|r| r.deny)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (exact, globs) = deny::partition_deny_entries(&entries);
|
||||
let mut deny_read = deny::exact_deny_path_strings(workspace, &exact);
|
||||
let has_globs = !globs.is_empty();
|
||||
if has_globs {
|
||||
tracing::warn!(
|
||||
count = globs.len(),
|
||||
"sandbox deny globs are enforced best-effort on Linux (expanded at launch); \
|
||||
files matching them that are created later are NOT covered"
|
||||
);
|
||||
deny_read.extend(deny::expand_deny_globs(
|
||||
workspace,
|
||||
&globs,
|
||||
deny::DENY_GLOB_MAX_DEPTH,
|
||||
deny::DENY_GLOB_MAX_MATCHES,
|
||||
deny::DENY_GLOB_MAX_ENTRIES,
|
||||
)?);
|
||||
}
|
||||
Some(BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_read,
|
||||
has_globs,
|
||||
})
|
||||
}
|
||||
/// Stub when `enforce` is unavailable on Linux: read-deny needs nono, so there is
|
||||
/// none — but the devbox `/data` write-deny is a plain bwrap mount and MUST still
|
||||
/// apply (devbox `/data` is always sandboxed), so it is preserved here.
|
||||
#[cfg(all(not(feature = "enforce"), target_os = "linux"))]
|
||||
fn bwrap_deny_plan(profile: &ProfileName, workspace: &Path) -> Option<BwrapDenyPlan> {
|
||||
let config = profiles::load_sandbox_config(workspace);
|
||||
let deny_write: Vec<String> = if is_devbox_based(profile, &config) {
|
||||
vec!["/data".to_string()]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Some(BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_read: Vec::new(),
|
||||
has_globs: false,
|
||||
})
|
||||
}
|
||||
/// Build the bwrap re-exec command needed on Linux, or `None` if no mount-namespace
|
||||
/// enforcement is needed (or we are already inside bwrap). Canonical routing:
|
||||
/// devbox — and a custom profile that `extends = "devbox"` — gets write-deny on
|
||||
/// `/data`; any profile gets read-deny on its own `deny` set. These compose, so a
|
||||
/// devbox-based custom profile with a `deny` list write-denies `/data` AND
|
||||
/// read-denies its deny paths in one re-exec.
|
||||
///
|
||||
/// Glob deny entries are expanded to concrete existing matches at launch and
|
||||
/// bound over too (best-effort; post-launch matches are not covered on Linux).
|
||||
/// Returns `None` (fail closed) if a glob blows past the expansion caps, so the
|
||||
/// shell's startup refuses to run with a broad glob under-enforced.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn bwrap_reexec_for_profile(
|
||||
profile: &ProfileName,
|
||||
workspace: &Path,
|
||||
) -> Option<std::process::Command> {
|
||||
let BwrapDenyPlan {
|
||||
deny_write,
|
||||
deny_read,
|
||||
has_globs,
|
||||
} = bwrap_deny_plan(profile, workspace)?;
|
||||
if deny_write.is_empty() && deny_read.is_empty() && !has_globs {
|
||||
return None;
|
||||
}
|
||||
let write_refs: Vec<&str> = deny_write.iter().map(String::as_str).collect();
|
||||
let read_refs: Vec<&str> = deny_read.iter().map(String::as_str).collect();
|
||||
bwrap_reexec_command(&write_refs, &read_refs)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serial_test::serial;
|
||||
/// Save, set/remove, and auto-restore an env var on drop.
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
prev: Option<String>,
|
||||
}
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, val: &str) -> Self {
|
||||
let prev = std::env::var(key).ok();
|
||||
unsafe { std::env::set_var(key, val) };
|
||||
Self { key, prev }
|
||||
}
|
||||
fn remove(key: &'static str) -> Self {
|
||||
let prev = std::env::var(key).ok();
|
||||
unsafe { std::env::remove_var(key) };
|
||||
Self { key, prev }
|
||||
}
|
||||
}
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.prev {
|
||||
Some(v) => unsafe { std::env::set_var(self.key, v) },
|
||||
None => unsafe { std::env::remove_var(self.key) },
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn bwrap_reexec_returns_none_inside_bwrap() {
|
||||
let _g = EnvGuard::set(BWRAP_ENV_VAR, "1");
|
||||
let result = bwrap_reexec_command(&["/data"], &[]);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"should return None when already inside bwrap"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn bwrap_reexec_returns_some_outside_bwrap() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
||||
assert!(result.is_some(), "should return Some when not inside bwrap");
|
||||
let cmd = result.unwrap();
|
||||
assert_eq!(cmd.get_program(), "bwrap", "program should be bwrap");
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn trust_bwrap_marker_for_devbox_tracks_env_when_feature_on() {
|
||||
let _g = EnvGuard::set(BWRAP_ENV_VAR, "1");
|
||||
assert!(
|
||||
!trust_bwrap_marker_for_devbox(),
|
||||
"without bwrap-marker the hatch must stay closed"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn trust_bwrap_marker_for_devbox_false_outside_bwrap() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
assert!(!trust_bwrap_marker_for_devbox());
|
||||
assert!(!is_inside_bwrap());
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn bwrap_reexec_skips_nonexistent_paths() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/nonexistent-test-path-xyz-12345"], &[]);
|
||||
let cmd = result.unwrap();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
!args.iter().any(|a| a == "/nonexistent-test-path-xyz-12345"),
|
||||
"should skip non-existent deny_write paths, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_reexec_binds_nonexistent_deny_read_paths() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let missing = "/nonexistent-deny-read-path-xyz-12345";
|
||||
let result = bwrap_reexec_command(&[], &[missing]);
|
||||
let cmd = result.unwrap();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let has_bind = args
|
||||
.windows(3)
|
||||
.any(|w| w[0] == "--ro-bind" && w[2] == missing);
|
||||
assert!(
|
||||
has_bind,
|
||||
"should bind-over non-existent deny_read paths, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn bwrap_reexec_mounts_existing_paths_read_only() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&["/tmp"], &[]);
|
||||
let cmd = result.unwrap();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let has_ro_bind = args.windows(3).any(|w| w == ["--ro-bind", "/tmp", "/tmp"]);
|
||||
assert!(
|
||||
has_ro_bind,
|
||||
"should mount existing paths as --ro-bind, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
fn bwrap_reexec_uses_dev_bind() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let result = bwrap_reexec_command(&[], &[]);
|
||||
let cmd = result.unwrap();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let has_dev_bind = args.windows(3).any(|w| w == ["--dev-bind", "/dev", "/dev"]);
|
||||
assert!(
|
||||
has_dev_bind,
|
||||
"should use --dev-bind for /dev passthrough, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn configured_profile_is_recorded() {
|
||||
set_configured_profile("read-only");
|
||||
assert_eq!(configured_profile_name(), Some("read-only"));
|
||||
}
|
||||
/// Create a temp workspace whose `.grok/sandbox.toml` contains `toml_body`.
|
||||
/// Returns the workspace path (caller removes it).
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn temp_workspace_with_sandbox_toml(tag: &str, toml_body: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let ws = std::env::temp_dir().join(format!("grok-{tag}-{}-{nanos}", std::process::id()));
|
||||
let grok = ws.join(".grok");
|
||||
std::fs::create_dir_all(&grok).unwrap();
|
||||
std::fs::write(grok.join("sandbox.toml"), toml_body).unwrap();
|
||||
ws
|
||||
}
|
||||
/// Create a temp workspace defining a `denytest` profile (extends `workspace`)
|
||||
/// with the given `deny` list. `deny_toml` is the raw TOML array body
|
||||
/// (e.g. `"\".env\""`).
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn temp_workspace_with_deny(tag: &str, deny_toml: &str) -> PathBuf {
|
||||
temp_workspace_with_sandbox_toml(
|
||||
tag,
|
||||
&format!("[profiles.denytest]\nextends = \"workspace\"\ndeny = [{deny_toml}]\n"),
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn requires_read_deny_only_for_custom_profile_with_deny() {
|
||||
let ws = temp_workspace_with_deny("requires-deny", "\".env\"");
|
||||
assert!(requires_read_deny(
|
||||
&ProfileName::Custom("denytest".to_string()),
|
||||
&ws
|
||||
));
|
||||
assert!(!requires_read_deny(
|
||||
&ProfileName::Custom("undefined".to_string()),
|
||||
&ws
|
||||
));
|
||||
assert!(!requires_read_deny(&ProfileName::Workspace, &ws));
|
||||
assert!(!requires_read_deny(&ProfileName::Strict, &ws));
|
||||
assert!(!requires_read_deny(&ProfileName::Devbox, &ws));
|
||||
assert!(!requires_read_deny(&ProfileName::Off, &ws));
|
||||
let _ = std::fs::remove_dir_all(&ws);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_reexec_uses_dir_placeholder_for_directories() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let dir = std::env::temp_dir().join(format!("grok-deny-dir-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dir_str = dir.to_string_lossy().to_string();
|
||||
let result = bwrap_reexec_command(&[], &[&dir_str]);
|
||||
let cmd = result.unwrap();
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let blocked_dir = paths::grok_home()
|
||||
.join(format!("sandbox-blocked-dir.{}", std::process::id()))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let has_dir_bind = args
|
||||
.windows(3)
|
||||
.any(|w| w[0] == "--ro-bind" && w[1] == blocked_dir && w[2] == dir_str);
|
||||
assert!(
|
||||
has_dir_bind,
|
||||
"existing directories should bind over sandbox-blocked-dir, got args: {args:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
#[test]
|
||||
#[serial(bwrap_env)]
|
||||
#[cfg(all(feature = "enforce", target_os = "linux"))]
|
||||
fn bwrap_reexec_for_profile_devbox_extends_composes_data_and_read_deny() {
|
||||
let _g = EnvGuard::remove(BWRAP_ENV_VAR);
|
||||
let ws = temp_workspace_with_sandbox_toml(
|
||||
"devbox-compose",
|
||||
"[profiles.devcustom]\nextends = \"devbox\"\ndeny = [\"secret.pem\"]\n",
|
||||
);
|
||||
let cmd = bwrap_reexec_for_profile(&ProfileName::Custom("devcustom".to_string()), &ws)
|
||||
.expect("devbox-extending custom with deny should build a re-exec command");
|
||||
let args: Vec<String> = cmd
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().to_string())
|
||||
.collect();
|
||||
let deny_path = ws.join("secret.pem").to_string_lossy().to_string();
|
||||
assert!(
|
||||
args.windows(3)
|
||||
.any(|w| w[0] == "--ro-bind" && w[2] == deny_path),
|
||||
"expected read-deny bind for {deny_path}, got args: {args:?}"
|
||||
);
|
||||
if Path::new("/data").exists() {
|
||||
assert!(
|
||||
args.windows(3)
|
||||
.any(|w| w == ["--ro-bind", "/data", "/data"]),
|
||||
"expected /data write-deny ro-bind, got args: {args:?}"
|
||||
);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&ws);
|
||||
let ws_empty = temp_workspace_with_sandbox_toml(
|
||||
"devbox-empty",
|
||||
"[profiles.devempty]\nextends = \"devbox\"\n",
|
||||
);
|
||||
assert!(
|
||||
bwrap_reexec_for_profile(&ProfileName::Custom("devempty".to_string()), &ws_empty)
|
||||
.is_some(),
|
||||
"devbox-extending custom must compose the /data write-deny re-exec"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&ws_empty);
|
||||
let ws_ws = temp_workspace_with_sandbox_toml(
|
||||
"ws-empty",
|
||||
"[profiles.wsempty]\nextends = \"workspace\"\n",
|
||||
);
|
||||
assert!(
|
||||
bwrap_reexec_for_profile(&ProfileName::Custom("wsempty".to_string()), &ws_ws).is_none(),
|
||||
"non-devbox custom with no deny needs no re-exec"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&ws_ws);
|
||||
}
|
||||
}
|
||||
105
crates/codegen/xai-grok-sandbox/src/logging.rs
Normal file
105
crates/codegen/xai-grok-sandbox/src/logging.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Sandbox event logger.
|
||||
//!
|
||||
//! Records sandbox events (profile applied, violations, bypasses) for
|
||||
//! telemetry and debugging. Events are kept in memory and can be flushed
|
||||
//! to a JSONL file at `~/.grok/sandbox-events.jsonl`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::types::{SandboxEvent, SandboxEventType, SandboxMetrics};
|
||||
|
||||
/// Logger that collects sandbox events and maintains violation counters.
|
||||
pub struct SandboxLogger {
|
||||
events: Mutex<Vec<SandboxEvent>>,
|
||||
metrics: SandboxMetrics,
|
||||
}
|
||||
|
||||
impl SandboxLogger {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(Vec::new()),
|
||||
metrics: SandboxMetrics::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an event, updating metrics counters as appropriate.
|
||||
pub fn log(&self, event: SandboxEvent) {
|
||||
match &event.event_type {
|
||||
SandboxEventType::FsViolation => self.metrics.inc_fs_violation(),
|
||||
SandboxEventType::NetViolation => self.metrics.inc_net_violation(),
|
||||
SandboxEventType::BypassGranted => self.metrics.inc_bypass_granted(),
|
||||
SandboxEventType::BypassDenied => self.metrics.inc_bypass_denied(),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
event_type = ?event.event_type,
|
||||
profile = %event.profile,
|
||||
target = ?event.target,
|
||||
operation = ?event.operation,
|
||||
"sandbox event"
|
||||
);
|
||||
|
||||
if let Ok(mut events) = self.events.lock() {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a reference to the metrics counters.
|
||||
pub fn metrics(&self) -> &SandboxMetrics {
|
||||
&self.metrics
|
||||
}
|
||||
|
||||
/// Take all accumulated events, draining the internal buffer.
|
||||
pub fn take_events(&self) -> Vec<SandboxEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.map(|mut events| std::mem::take(&mut *events))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Flush accumulated events to the JSONL log file.
|
||||
/// Each event is written as a single JSON line.
|
||||
pub fn flush_to_disk(&self) -> anyhow::Result<()> {
|
||||
let events = self.take_events();
|
||||
if events.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let log_path = Self::log_file_path();
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
|
||||
for event in &events {
|
||||
if let Ok(json) = serde_json::to_string(event) {
|
||||
writeln!(file, "{}", json)?;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
path = %log_path.display(),
|
||||
count = events.len(),
|
||||
"flushed sandbox events to disk"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_file_path() -> PathBuf {
|
||||
xai_grok_config::grok_home().join("sandbox-events.jsonl")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxLogger {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
98
crates/codegen/xai-grok-sandbox/src/paths.rs
Normal file
98
crates/codegen/xai-grok-sandbox/src/paths.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! Filesystem path tables for sandbox profiles.
|
||||
//!
|
||||
//! Collects device files, temp directories, sensitive deny-paths, and
|
||||
//! ecosystem (package-manager / toolchain) writable paths into helpers
|
||||
//! consumed by [`super::profiles`].
|
||||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ── Grok state directory ────────────────────────────────────────────────────
|
||||
|
||||
/// Grok state directory — always writable (`$GROK_HOME` or `~/.grok`).
|
||||
pub(crate) fn grok_home() -> PathBuf {
|
||||
xai_grok_config::grok_home()
|
||||
}
|
||||
|
||||
// ── Device files & directories ──────────────────────────────────────────────
|
||||
|
||||
/// Device files that need write access for normal tool operation.
|
||||
///
|
||||
/// Without write access to these, common programs (git, curl, ssh, compilers)
|
||||
/// break because they can't open `/dev/null` as an output sink, allocate PTYs,
|
||||
/// or seed RNGs.
|
||||
///
|
||||
/// These are individual files (use `allow_file`, not `allow_path`).
|
||||
/// `/dev/pts` is a directory (PTY slaves on Linux) so it uses `allow_path`.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) const DEVICE_FILES: &[&str] = &[
|
||||
"/dev/null", // output sink — used by virtually every CLI tool
|
||||
"/dev/zero", // zero source — used by memory allocators
|
||||
"/dev/random", // entropy — used by crypto/TLS
|
||||
"/dev/urandom", // entropy — used by crypto/TLS
|
||||
"/dev/tty", // controlling terminal — used by git, ssh, gpg
|
||||
"/dev/ptmx", // PTY allocation — used by terminal spawning
|
||||
"/dev/fd", // file descriptor access (symlink to /proc/self/fd on Linux)
|
||||
];
|
||||
|
||||
/// Device directories that need write access.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) const DEVICE_DIRS: &[&str] = &[
|
||||
"/dev/pts", // PTY slaves (Linux)
|
||||
];
|
||||
|
||||
// ── Temporary directories ───────────────────────────────────────────────────
|
||||
|
||||
/// Temporary directories that need write access.
|
||||
///
|
||||
/// On Linux, `/tmp` is the standard temp directory.
|
||||
/// On macOS, programs use both `/tmp` (symlink to `/private/tmp`) and
|
||||
/// `/private/var/folders/` (the real `TMPDIR` / `NSTemporaryDirectory()`).
|
||||
/// git, compilers, and other tools write temp files to `$TMPDIR` which
|
||||
/// resolves to `/private/var/folders/xx/.../T/` on macOS.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn temp_writable_paths() -> Vec<PathBuf> {
|
||||
let mut paths = vec![PathBuf::from("/tmp"), PathBuf::from("/var/tmp")];
|
||||
|
||||
// macOS: /tmp → /private/tmp, but the real TMPDIR is under /private/var/folders.
|
||||
// Also include /private/tmp since Seatbelt may resolve the symlink.
|
||||
if cfg!(target_os = "macos") {
|
||||
for p in ["/private/tmp", "/private/var/tmp", "/private/var/folders"] {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.exists() && pb.is_dir() {
|
||||
paths.push(pb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respect $TMPDIR if it points somewhere else (e.g. custom Linux setups).
|
||||
if let Ok(tmpdir) = std::env::var("TMPDIR") {
|
||||
let pb = PathBuf::from(&tmpdir);
|
||||
if pb.exists() && pb.is_dir() && !paths.contains(&pb) {
|
||||
paths.push(pb);
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
// ── Essential writable paths ────────────────────────────────────────────────
|
||||
|
||||
/// Writable directory paths for profiles that allow workspace writes (workspace, devbox, strict).
|
||||
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn essential_writable_paths(workspace: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![workspace.to_path_buf(), grok_home()];
|
||||
paths.extend(temp_writable_paths());
|
||||
paths
|
||||
}
|
||||
|
||||
/// Writable directory paths for the read-only profile (minimal: just ~/.grok + temp).
|
||||
/// Device files are handled separately via `allow_file` in `to_capability_set_with_config`.
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
pub(crate) fn essential_writable_paths_minimal() -> Vec<PathBuf> {
|
||||
let mut paths = vec![grok_home()];
|
||||
paths.extend(temp_writable_paths());
|
||||
paths
|
||||
}
|
||||
760
crates/codegen/xai-grok-sandbox/src/profiles.rs
Normal file
760
crates/codegen/xai-grok-sandbox/src/profiles.rs
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
//! Sandbox profiles. Built-in: `workspace`, `devbox`, `read-only`, `strict`,
|
||||
//! `off`. Custom profiles via `~/.grok/sandbox.toml` or `.grok/sandbox.toml`.
|
||||
//! A custom profile's `deny` list is kernel-enforced (read + write/rename) on
|
||||
//! both platforms.
|
||||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use nono::{AccessMode, CapabilitySet};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use crate::deny::{
|
||||
apply_deny_globs_to_capability_set, apply_deny_paths_to_capability_set, effective_deny_paths,
|
||||
partition_deny_entries,
|
||||
};
|
||||
use crate::paths::grok_home;
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
use crate::paths::{
|
||||
DEVICE_DIRS, DEVICE_FILES, essential_writable_paths, essential_writable_paths_minimal,
|
||||
};
|
||||
|
||||
/// A resolved sandbox profile ready to be converted to a `CapabilitySet`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SandboxProfile {
|
||||
/// Display name
|
||||
pub name: String,
|
||||
/// Paths the agent can read (but not write)
|
||||
pub read_only: Vec<PathBuf>,
|
||||
/// Paths the agent can read and write
|
||||
pub read_write: Vec<PathBuf>,
|
||||
/// Paths denied entirely (overrides read_only/read_write)
|
||||
pub deny: Vec<PathBuf>,
|
||||
/// Whether to grant read access to the entire filesystem by default
|
||||
pub default_read: bool,
|
||||
/// Whether child processes should have network blocked
|
||||
pub restrict_network: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
pub struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
pub extends: Option<String>,
|
||||
#[serde(default)]
|
||||
pub restrict_network: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub read_only: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub read_write: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub deny: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||
pub struct SandboxConfig {
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, ProfileConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ProfileName {
|
||||
#[default]
|
||||
Workspace,
|
||||
Devbox,
|
||||
ReadOnly,
|
||||
Strict,
|
||||
Off,
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl ProfileName {
|
||||
pub fn restricts_network(&self) -> bool {
|
||||
matches!(self, Self::ReadOnly | Self::Strict)
|
||||
}
|
||||
|
||||
/// Resolve network restriction from config (handles Custom profiles).
|
||||
pub fn restricts_network_resolved(&self, config: &SandboxConfig) -> bool {
|
||||
match self {
|
||||
Self::ReadOnly | Self::Strict => true,
|
||||
Self::Workspace | Self::Devbox | Self::Off => false,
|
||||
Self::Custom(name) => config
|
||||
.profiles
|
||||
.get(name)
|
||||
.and_then(|p| p.restrict_network)
|
||||
.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProfileName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Workspace => write!(f, "workspace"),
|
||||
Self::Devbox => write!(f, "devbox"),
|
||||
Self::ReadOnly => write!(f, "read-only"),
|
||||
Self::Strict => write!(f, "strict"),
|
||||
Self::Off => write!(f, "off"),
|
||||
Self::Custom(name) => write!(f, "{name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProfileName {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"workspace" => Ok(Self::Workspace),
|
||||
"devbox" => Ok(Self::Devbox),
|
||||
"read-only" | "readonly" => Ok(Self::ReadOnly),
|
||||
"strict" => Ok(Self::Strict),
|
||||
"off" | "none" => Ok(Self::Off),
|
||||
// Anything else is treated as a custom profile name.
|
||||
// Validation happens when we try to load it from config.
|
||||
other => Ok(Self::Custom(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load sandbox config from `~/.grok/sandbox.toml` and `.grok/sandbox.toml`.
|
||||
///
|
||||
/// Project config may **add** new profile names only. It cannot redefine a
|
||||
/// name already present in the global config — last-write-wins would let a
|
||||
/// malicious workspace hollow out a user/enterprise custom profile (e.g.
|
||||
/// empty `deny` / broad `read_write`) while keeping the trusted name.
|
||||
pub fn load_sandbox_config(workspace: &Path) -> SandboxConfig {
|
||||
let mut config = SandboxConfig::default();
|
||||
|
||||
// Global config: ~/.grok/sandbox.toml
|
||||
let global_path = grok_home().join("sandbox.toml");
|
||||
if let Some(global) = load_config_file(&global_path) {
|
||||
config = global;
|
||||
}
|
||||
|
||||
// Project config: <workspace>/.grok/sandbox.toml (additive only)
|
||||
let project_path = workspace.join(".grok").join("sandbox.toml");
|
||||
if let Some(project) = load_config_file(&project_path) {
|
||||
merge_project_profiles(&mut config, project);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn sandbox_profile_conflicts(workspace: &Path) -> Vec<String> {
|
||||
let global = load_config_file(&grok_home().join("sandbox.toml")).unwrap_or_default();
|
||||
let project =
|
||||
load_config_file(&workspace.join(".grok").join("sandbox.toml")).unwrap_or_default();
|
||||
mismatched_profile_names(&global, &project)
|
||||
}
|
||||
|
||||
fn mismatched_profile_names(global: &SandboxConfig, project: &SandboxConfig) -> Vec<String> {
|
||||
let mut names: Vec<String> = project
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|(name, _)| matches!(name.parse(), Ok(ProfileName::Custom(_))))
|
||||
.filter_map(|(name, project_profile)| {
|
||||
global
|
||||
.profiles
|
||||
.get(name)
|
||||
.filter(|global_profile| *global_profile != project_profile)
|
||||
.map(|_| name.to_owned())
|
||||
})
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names
|
||||
}
|
||||
|
||||
/// Merge project profiles into `config`. Names already defined globally are
|
||||
/// ignored so a workspace cannot replace a global custom profile's policy.
|
||||
fn merge_project_profiles(config: &mut SandboxConfig, project: SandboxConfig) {
|
||||
for (name, profile) in project.profiles {
|
||||
config.profiles.entry(name).or_insert(profile);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_config_file(path: &Path) -> Option<SandboxConfig> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
match toml::from_str(&content) {
|
||||
Ok(config) => Some(config),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "Failed to parse sandbox config");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
impl ProfileName {
|
||||
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
|
||||
pub fn to_capability_set(&self, workspace: &Path) -> anyhow::Result<CapabilitySet> {
|
||||
let config = load_sandbox_config(workspace);
|
||||
self.to_capability_set_with_config(workspace, &config)
|
||||
}
|
||||
|
||||
/// Convert using an already-loaded config (avoids re-reading disk).
|
||||
///
|
||||
/// A custom profile's own `deny` list is kernel-enforced (read + write/rename)
|
||||
/// on top of the base profile.
|
||||
pub fn to_capability_set_with_config(
|
||||
&self,
|
||||
workspace: &Path,
|
||||
config: &SandboxConfig,
|
||||
) -> anyhow::Result<CapabilitySet> {
|
||||
if *self == Self::Off {
|
||||
return Ok(CapabilitySet::new());
|
||||
}
|
||||
|
||||
// Resolve to a SandboxProfile
|
||||
let profile = self.resolve(workspace, config)?;
|
||||
|
||||
// Build CapabilitySet from the resolved profile
|
||||
let mut caps = CapabilitySet::new();
|
||||
|
||||
// Default read access
|
||||
if profile.default_read {
|
||||
caps = caps.allow_path("/", AccessMode::Read)?;
|
||||
}
|
||||
|
||||
// Explicit read-only paths — skip non-existent (nothing to read)
|
||||
for path in &profile.read_only {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let Some(path_str) = path.to_str() else {
|
||||
tracing::warn!(path = ?path, "Skipping non-UTF8 read_only path");
|
||||
continue;
|
||||
};
|
||||
caps = caps.allow_path(path_str, AccessMode::Read)?;
|
||||
}
|
||||
|
||||
// Read-write paths. nono/Landlock need the directory to exist at
|
||||
// apply time (it opens an O_PATH fd), but new files within it can
|
||||
// be created freely after the sandbox is applied. Pre-create
|
||||
// directories like ~/.grok/ that may not exist on first run.
|
||||
for path in &profile.read_write {
|
||||
if !path.exists() && std::fs::create_dir_all(path).is_err() {
|
||||
tracing::warn!(path = ?path, "read_write path does not exist and could not be created, skipping");
|
||||
continue;
|
||||
}
|
||||
let Some(path_str) = path.to_str() else {
|
||||
tracing::warn!(path = ?path, "Skipping non-UTF8 read_write path");
|
||||
continue;
|
||||
};
|
||||
caps = caps.allow_path(path_str, AccessMode::ReadWrite)?;
|
||||
}
|
||||
|
||||
// Device special files (character devices like /dev/null, /dev/tty, etc.).
|
||||
for dev in DEVICE_FILES {
|
||||
let p = Path::new(dev);
|
||||
if !p.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = caps.allow_file_mut(p, AccessMode::ReadWrite) {
|
||||
tracing::warn!(path = dev, error = %e, "Could not allow device file");
|
||||
}
|
||||
}
|
||||
// Device directories (e.g. /dev/pts for PTY slaves on Linux).
|
||||
for dev in DEVICE_DIRS {
|
||||
let p = Path::new(dev);
|
||||
if p.exists() && p.is_dir() {
|
||||
caps = caps.allow_path(dev, AccessMode::ReadWrite)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Kernel deny (read+write): macOS Seatbelt rules; Linux via bwrap bind-over.
|
||||
// The effective deny set is the profile's own `deny` (custom profiles only;
|
||||
// built-ins carry an empty `deny`). An empty set means there is nothing to
|
||||
// enforce. Keying on emptiness rather than profile type avoids enforcing
|
||||
// unintentional denies.
|
||||
//
|
||||
// Split exact paths from globs: exact paths keep the literal/subpath flow;
|
||||
// globs become anchored Seatbelt regexes on macOS (a no-op here on Linux,
|
||||
// where they are expanded and bound over at bwrap re-exec).
|
||||
let (exact_deny, glob_deny) = partition_deny_entries(&profile.deny);
|
||||
let all_denied = effective_deny_paths(workspace, &exact_deny);
|
||||
if !all_denied.is_empty() {
|
||||
apply_deny_paths_to_capability_set(&mut caps, &all_denied)?;
|
||||
}
|
||||
if !glob_deny.is_empty() {
|
||||
apply_deny_globs_to_capability_set(&mut caps, workspace, &glob_deny)?;
|
||||
}
|
||||
|
||||
Ok(caps)
|
||||
}
|
||||
|
||||
/// Resolve this profile into a fully-specified `SandboxProfile` for logging.
|
||||
pub fn resolve_profile(
|
||||
&self,
|
||||
workspace: &Path,
|
||||
config: &SandboxConfig,
|
||||
) -> anyhow::Result<SandboxProfile> {
|
||||
self.resolve(workspace, config)
|
||||
}
|
||||
|
||||
fn resolve(&self, workspace: &Path, config: &SandboxConfig) -> anyhow::Result<SandboxProfile> {
|
||||
match self {
|
||||
// Selected `off` is handled before resolve (empty CapabilitySet /
|
||||
// early return in apply). Reaching here is almost always a custom
|
||||
// profile with `extends = "off"` / `"none"` — return Err, never panic.
|
||||
Self::Off => anyhow::bail!(
|
||||
"sandbox profile 'off' cannot be resolved as a base profile; \
|
||||
choose a built-in base (workspace, devbox, read-only, strict)"
|
||||
),
|
||||
|
||||
Self::Workspace => Ok(SandboxProfile {
|
||||
name: "workspace".to_string(),
|
||||
read_only: vec![],
|
||||
read_write: essential_writable_paths(workspace),
|
||||
deny: vec![],
|
||||
default_read: true,
|
||||
restrict_network: false,
|
||||
}),
|
||||
|
||||
Self::Devbox => {
|
||||
// Everything writable except /data. Enumerate top-level
|
||||
// dirs and skip the exclusion list. Can't grant "/" because
|
||||
// Landlock has no deny_path — sub-path exceptions are
|
||||
// only possible by not granting the parent.
|
||||
//
|
||||
// /data is excluded from read_write here (so it is not writable)
|
||||
// but is deliberately NOT a kernel-deny: it stays readable via
|
||||
// default_read, and its Linux write-deny comes from the
|
||||
// bwrap_reexec_command(&["/data"]) re-exec, not from profile.deny.
|
||||
// Keeping deny empty stops a custom profile that extends devbox
|
||||
// from inheriting /data into the enforced kernel-deny set.
|
||||
let exclude = [PathBuf::from("/data")];
|
||||
let mut read_write = vec![workspace.to_path_buf()];
|
||||
if let Ok(entries) = std::fs::read_dir("/") {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if exclude.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
// Skip virtual filesystems (handled separately)
|
||||
if matches!(path.to_str(), Some("/proc" | "/sys" | "/dev")) {
|
||||
continue;
|
||||
}
|
||||
if path.is_dir() {
|
||||
read_write.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(SandboxProfile {
|
||||
name: "devbox".to_string(),
|
||||
read_only: vec![],
|
||||
read_write,
|
||||
deny: vec![],
|
||||
default_read: true,
|
||||
restrict_network: false,
|
||||
})
|
||||
}
|
||||
|
||||
Self::ReadOnly => Ok(SandboxProfile {
|
||||
name: "read-only".to_string(),
|
||||
read_only: vec![],
|
||||
read_write: essential_writable_paths_minimal(),
|
||||
deny: vec![],
|
||||
default_read: true,
|
||||
restrict_network: true,
|
||||
}),
|
||||
|
||||
Self::Strict => {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
|
||||
let system_read: Vec<PathBuf> = [
|
||||
"/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/dev", "/proc", "/sys",
|
||||
"/tmp",
|
||||
// Landlock realpath: /etc/resolv.conf often → /run/systemd/resolve/…
|
||||
"/run",
|
||||
// NSS/SSSD (and similar) under /var — needed beyond resolv.conf alone
|
||||
"/var",
|
||||
// macOS-specific paths (filtered by exists() below)
|
||||
"/System", // Security framework, dylibs, TLS certificates
|
||||
"/Library", // System-wide frameworks
|
||||
"/private", // Real path behind /etc, /tmp, /var symlinks
|
||||
]
|
||||
.iter()
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
// ~/Library is needed for macOS keychain access (TLS cert validation)
|
||||
.chain(std::iter::once(home.join("Library")))
|
||||
.filter(|p| p.exists())
|
||||
.chain(std::iter::once(workspace.to_path_buf()))
|
||||
.collect();
|
||||
|
||||
Ok(SandboxProfile {
|
||||
name: "strict".to_string(),
|
||||
read_only: system_read,
|
||||
read_write: essential_writable_paths(workspace),
|
||||
deny: vec![],
|
||||
default_read: false,
|
||||
restrict_network: true,
|
||||
})
|
||||
}
|
||||
|
||||
Self::Custom(name) => {
|
||||
let profile_config = config.profiles.get(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Custom sandbox profile '{name}' not found. \
|
||||
Define it in ~/.grok/sandbox.toml or .grok/sandbox.toml:\n\n\
|
||||
[profiles.{name}]\n\
|
||||
extends = \"workspace\"\n\
|
||||
read_only = [\"/data\"]\n"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Start from the base profile if `extends` is set
|
||||
let mut profile = if let Some(base_name) = &profile_config.extends {
|
||||
let base: ProfileName = base_name.parse().map_err(|e: String| {
|
||||
anyhow::anyhow!("Profile '{name}' extends invalid base: {e}")
|
||||
})?;
|
||||
if matches!(base, Self::Off) {
|
||||
anyhow::bail!(
|
||||
"Profile '{name}' extends '{base_name}', but 'off'/'none' \
|
||||
is not a valid base profile"
|
||||
);
|
||||
}
|
||||
if matches!(base, Self::Custom(_)) {
|
||||
anyhow::bail!(
|
||||
"Profile '{name}' extends '{base_name}', but custom profiles \
|
||||
cannot extend other custom profiles (only built-ins)"
|
||||
);
|
||||
}
|
||||
base.resolve(workspace, config)?
|
||||
} else {
|
||||
// Default: start from workspace
|
||||
Self::Workspace.resolve(workspace, config)?
|
||||
};
|
||||
|
||||
profile.name = name.clone();
|
||||
|
||||
// Apply overrides from the custom config
|
||||
if let Some(restrict_net) = profile_config.restrict_network {
|
||||
profile.restrict_network = restrict_net;
|
||||
}
|
||||
|
||||
// Add custom read-only paths
|
||||
for path_str in &profile_config.read_only {
|
||||
profile.read_only.push(PathBuf::from(path_str));
|
||||
}
|
||||
|
||||
// Add custom read-write paths
|
||||
for path_str in &profile_config.read_write {
|
||||
profile.read_write.push(PathBuf::from(path_str));
|
||||
}
|
||||
|
||||
// Add custom deny paths
|
||||
for path_str in &profile_config.deny {
|
||||
profile.deny.push(PathBuf::from(path_str));
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_profile_names() {
|
||||
assert_eq!(
|
||||
"workspace".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::Workspace
|
||||
);
|
||||
assert_eq!(
|
||||
"devbox".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::Devbox
|
||||
);
|
||||
assert_eq!(
|
||||
"read-only".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
"readonly".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::ReadOnly
|
||||
);
|
||||
assert_eq!(
|
||||
"strict".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::Strict
|
||||
);
|
||||
assert_eq!("off".parse::<ProfileName>().unwrap(), ProfileName::Off);
|
||||
assert_eq!("none".parse::<ProfileName>().unwrap(), ProfileName::Off);
|
||||
// Unknown names become Custom profiles
|
||||
assert_eq!(
|
||||
"my-custom-profile".parse::<ProfileName>().unwrap(),
|
||||
ProfileName::Custom("my-custom-profile".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_roundtrip() {
|
||||
for profile in [
|
||||
ProfileName::Workspace,
|
||||
ProfileName::Devbox,
|
||||
ProfileName::ReadOnly,
|
||||
ProfileName::Strict,
|
||||
ProfileName::Off,
|
||||
] {
|
||||
let s = profile.to_string();
|
||||
let parsed: ProfileName = s.parse().unwrap();
|
||||
assert_eq!(parsed, profile);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_custom() {
|
||||
let p = ProfileName::Custom("my-custom".to_string());
|
||||
assert_eq!(p.to_string(), "my-custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_restriction() {
|
||||
assert!(!ProfileName::Workspace.restricts_network());
|
||||
assert!(!ProfileName::Devbox.restricts_network());
|
||||
assert!(ProfileName::ReadOnly.restricts_network());
|
||||
assert!(ProfileName::Strict.restricts_network());
|
||||
assert!(!ProfileName::Off.restricts_network());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn strict_allowlist_includes_run_and_var_when_present() {
|
||||
// Regression: /run (resolv realpath) + /var (NSS/SSSD) when present.
|
||||
let workspace = std::env::temp_dir();
|
||||
let profile = ProfileName::Strict
|
||||
.resolve_profile(&workspace, &SandboxConfig::default())
|
||||
.expect("strict resolves");
|
||||
assert!(!profile.default_read);
|
||||
if PathBuf::from("/run").exists() {
|
||||
assert!(
|
||||
profile.read_only.iter().any(|p| p == Path::new("/run")),
|
||||
"strict read_only must include exact /run for systemd-resolved DNS; got {:?}",
|
||||
profile.read_only
|
||||
);
|
||||
}
|
||||
if PathBuf::from("/var").exists() {
|
||||
assert!(
|
||||
profile.read_only.iter().any(|p| p == Path::new("/var")),
|
||||
"strict read_only must include exact /var for NSS/SSSD; got {:?}",
|
||||
profile.read_only
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn base_profile_capability_set_builds() {
|
||||
// A base profile with no `deny` builds a CapabilitySet without erroring.
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig::default();
|
||||
let result = ProfileName::Workspace.to_capability_set_with_config(&workspace, &config);
|
||||
assert!(result.is_ok(), "Failed: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn custom_profile_from_config() {
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig {
|
||||
profiles: HashMap::from([(
|
||||
"project".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("workspace".to_string()),
|
||||
restrict_network: Some(true),
|
||||
read_only: vec!["/data".to_string()],
|
||||
read_write: vec![],
|
||||
deny: vec!["/data/private".to_string()],
|
||||
},
|
||||
)]),
|
||||
};
|
||||
|
||||
let profile = ProfileName::Custom("project".to_string());
|
||||
let result = profile.to_capability_set_with_config(&workspace, &config);
|
||||
assert!(result.is_ok(), "Failed: {:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn custom_extends_devbox_has_no_data_in_deny() {
|
||||
// Regression: devbox excludes /data via a local list, not profile.deny, so
|
||||
// a custom profile extending devbox must not inherit /data into the kernel
|
||||
// deny set (which would wrongly read-deny /data and force fail-closed).
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig {
|
||||
profiles: HashMap::from([(
|
||||
"mydev".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("devbox".to_string()),
|
||||
restrict_network: None,
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
deny: vec![],
|
||||
},
|
||||
)]),
|
||||
};
|
||||
let profile = ProfileName::Custom("mydev".to_string());
|
||||
let resolved = profile.resolve_profile(&workspace, &config).unwrap();
|
||||
assert!(
|
||||
!resolved.deny.contains(&PathBuf::from("/data")),
|
||||
"custom profile extending devbox must not inherit /data into deny: {:?}",
|
||||
resolved.deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn custom_profile_not_found() {
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig::default();
|
||||
|
||||
let profile = ProfileName::Custom("nonexistent".to_string());
|
||||
let result = profile.to_capability_set_with_config(&workspace, &config);
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(err.contains("not found"), "Unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_profile_names_reports_only_changed_custom_profiles() {
|
||||
let profile = |restrict_network| ProfileConfig {
|
||||
extends: Some("workspace".to_string()),
|
||||
restrict_network: Some(restrict_network),
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
deny: vec![],
|
||||
};
|
||||
let global = SandboxConfig {
|
||||
profiles: HashMap::from([
|
||||
("dev".to_string(), profile(false)),
|
||||
("same".to_string(), profile(false)),
|
||||
]),
|
||||
};
|
||||
let project = SandboxConfig {
|
||||
profiles: HashMap::from([
|
||||
("dev".to_string(), profile(true)),
|
||||
("same".to_string(), profile(false)),
|
||||
("project-only".to_string(), profile(true)),
|
||||
("devbox".to_string(), profile(true)),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(mismatched_profile_names(&global, &project), vec!["dev"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_toml_config() {
|
||||
let toml_str = r#"
|
||||
[profiles.devbox]
|
||||
extends = "workspace"
|
||||
restrict_network = true
|
||||
read_only = ["/data"]
|
||||
deny = ["/data/private"]
|
||||
|
||||
[profiles.ci]
|
||||
extends = "strict"
|
||||
read_write = ["/tmp/ci-artifacts"]
|
||||
"#;
|
||||
let config: SandboxConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.profiles.len(), 2);
|
||||
assert!(config.profiles.contains_key("devbox"));
|
||||
assert!(config.profiles.contains_key("ci"));
|
||||
assert_eq!(config.profiles["devbox"].read_only, vec!["/data"]);
|
||||
assert_eq!(config.profiles["devbox"].deny, vec!["/data/private"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_cannot_redefine_global_profile() {
|
||||
// Global "secure" with a real deny list must win over a project hollow-out.
|
||||
let mut config = SandboxConfig {
|
||||
profiles: HashMap::from([(
|
||||
"secure".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("workspace".to_string()),
|
||||
restrict_network: Some(true),
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
deny: vec!["/home/user/.ssh".to_string()],
|
||||
},
|
||||
)]),
|
||||
};
|
||||
let project = SandboxConfig {
|
||||
profiles: HashMap::from([
|
||||
(
|
||||
"secure".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("workspace".to_string()),
|
||||
restrict_network: Some(false),
|
||||
read_only: vec![],
|
||||
read_write: vec!["/".to_string()],
|
||||
deny: vec![],
|
||||
},
|
||||
),
|
||||
(
|
||||
"project-only".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("workspace".to_string()),
|
||||
restrict_network: None,
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
deny: vec!["./secrets".to_string()],
|
||||
},
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
merge_project_profiles(&mut config, project);
|
||||
|
||||
assert_eq!(
|
||||
config.profiles["secure"].deny,
|
||||
vec!["/home/user/.ssh".to_string()],
|
||||
"global deny must be preserved"
|
||||
);
|
||||
assert_eq!(config.profiles["secure"].restrict_network, Some(true));
|
||||
assert!(
|
||||
config.profiles["secure"].read_write.is_empty(),
|
||||
"project must not widen global read_write"
|
||||
);
|
||||
assert!(
|
||||
config.profiles.contains_key("project-only"),
|
||||
"new project-only profile names are still allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn extends_off_returns_err_not_panic() {
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let config = SandboxConfig {
|
||||
profiles: HashMap::from([(
|
||||
"broken".to_string(),
|
||||
ProfileConfig {
|
||||
extends: Some("off".to_string()),
|
||||
restrict_network: None,
|
||||
read_only: vec![],
|
||||
read_write: vec![],
|
||||
deny: vec![],
|
||||
},
|
||||
)]),
|
||||
};
|
||||
let err = ProfileName::Custom("broken".to_string())
|
||||
.resolve_profile(&workspace, &config)
|
||||
.expect_err("extends=off must Err");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("off") || msg.contains("none"),
|
||||
"unexpected error: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn resolve_off_returns_err_not_panic() {
|
||||
let workspace = std::env::current_dir().unwrap();
|
||||
let err = ProfileName::Off
|
||||
.resolve_profile(&workspace, &SandboxConfig::default())
|
||||
.expect_err("Off.resolve must Err");
|
||||
assert!(err.to_string().contains("off"), "unexpected error: {err}");
|
||||
}
|
||||
}
|
||||
193
crates/codegen/xai-grok-sandbox/src/types.rs
Normal file
193
crates/codegen/xai-grok-sandbox/src/types.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Types for sandbox events, metrics, and profile configuration.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// A recorded sandbox event for telemetry and debugging.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxEvent {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub event_type: SandboxEventType,
|
||||
pub profile: String,
|
||||
|
||||
// Context fields — present on ProfileApplied/ApplyFailed
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub platform: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enforced: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restrict_network: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_write_paths: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub read_only_paths: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deny_paths: Option<Vec<String>>,
|
||||
|
||||
// Violation/error fields
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub operation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub command: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl SandboxEvent {
|
||||
fn base(event_type: SandboxEventType, profile: &str) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
event_type,
|
||||
profile: profile.to_string(),
|
||||
workspace: None,
|
||||
platform: None,
|
||||
enforced: None,
|
||||
restrict_network: None,
|
||||
read_write_paths: None,
|
||||
read_only_paths: None,
|
||||
deny_paths: None,
|
||||
operation: None,
|
||||
target: None,
|
||||
command: None,
|
||||
tool_call_id: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a "profile applied" event with full context.
|
||||
pub fn profile_applied(
|
||||
profile: &str,
|
||||
workspace: &std::path::Path,
|
||||
resolved: &crate::profiles::SandboxProfile,
|
||||
) -> Self {
|
||||
let platform = if cfg!(target_os = "linux") {
|
||||
"linux/landlock"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos/seatbelt"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let mut event = Self::base(SandboxEventType::ProfileApplied, profile);
|
||||
event.workspace = Some(workspace.display().to_string());
|
||||
event.platform = Some(platform.to_string());
|
||||
event.enforced = Some(true);
|
||||
event.restrict_network = Some(resolved.restrict_network);
|
||||
event.read_write_paths = Some(
|
||||
resolved
|
||||
.read_write
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect(),
|
||||
);
|
||||
if !resolved.read_only.is_empty() {
|
||||
event.read_only_paths = Some(
|
||||
resolved
|
||||
.read_only
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if !resolved.deny.is_empty() {
|
||||
event.deny_paths = Some(
|
||||
resolved
|
||||
.deny
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
/// Create an "apply failed" event with context.
|
||||
pub fn apply_failed(
|
||||
profile: &str,
|
||||
workspace: &std::path::Path,
|
||||
error: &dyn std::fmt::Display,
|
||||
) -> Self {
|
||||
let platform = if cfg!(target_os = "linux") {
|
||||
"linux/landlock"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos/seatbelt"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let mut event = Self::base(SandboxEventType::ApplyFailed, profile);
|
||||
event.workspace = Some(workspace.display().to_string());
|
||||
event.platform = Some(platform.to_string());
|
||||
event.enforced = Some(false);
|
||||
event.error = Some(error.to_string());
|
||||
event
|
||||
}
|
||||
|
||||
/// Create a filesystem violation event.
|
||||
pub fn fs_violation(profile: &str, target: &str, operation: &str) -> Self {
|
||||
let mut event = Self::base(SandboxEventType::FsViolation, profile);
|
||||
event.operation = Some(operation.to_string());
|
||||
event.target = Some(target.to_string());
|
||||
event
|
||||
}
|
||||
|
||||
/// Create a network violation event.
|
||||
pub fn net_violation(profile: &str, target: &str) -> Self {
|
||||
let mut event = Self::base(SandboxEventType::NetViolation, profile);
|
||||
event.operation = Some("connect".to_string());
|
||||
event.target = Some(target.to_string());
|
||||
event
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SandboxEventType {
|
||||
ProfileApplied,
|
||||
ApplyFailed,
|
||||
FsViolation,
|
||||
NetViolation,
|
||||
BypassGranted,
|
||||
BypassDenied,
|
||||
}
|
||||
|
||||
/// Counters for sandbox activity, used for telemetry dashboards.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SandboxMetrics {
|
||||
pub fs_violations: AtomicU64,
|
||||
pub net_violations: AtomicU64,
|
||||
pub bypasses_granted: AtomicU64,
|
||||
pub bypasses_denied: AtomicU64,
|
||||
}
|
||||
|
||||
impl SandboxMetrics {
|
||||
pub fn inc_fs_violation(&self) {
|
||||
self.fs_violations.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn inc_net_violation(&self) {
|
||||
self.net_violations.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn inc_bypass_granted(&self) {
|
||||
self.bypasses_granted.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn inc_bypass_denied(&self) {
|
||||
self.bypasses_denied.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn fs_violation_count(&self) -> u64 {
|
||||
self.fs_violations.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn net_violation_count(&self) -> u64 {
|
||||
self.net_violations.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
447
crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs
Normal file
447
crates/codegen/xai-grok-sandbox/tests/deny_paths_e2e.rs
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
//! E2E enforcement tests for kernel-enforced profile `deny` paths.
|
||||
//!
|
||||
//! Drives the GENERIC path-deny primitive via a custom sandbox profile whose
|
||||
//! `deny` list names concrete files. `SandboxManager::apply` is process-wide and
|
||||
//! irreversible, so kernel enforcement is verified in an isolated subprocess.
|
||||
//!
|
||||
//! On Linux, read-deny requires bwrap bind-over; the subprocess re-execs inside
|
||||
//! bwrap when `bwrap` is available. macOS uses Seatbelt platform rules directly
|
||||
//! via `SandboxManager::apply`.
|
||||
|
||||
#![cfg(all(unix, feature = "enforce"))]
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const SCENARIO_ENV: &str = "SANDBOX_E2E_SCENARIO";
|
||||
const WORKSPACE_ENV: &str = "SANDBOX_E2E_WORKSPACE";
|
||||
/// Custom profile name, comma-joined deny targets, and comma-joined control
|
||||
/// files, passed to the subprocess so one entry point drives every deny case
|
||||
/// (exact paths and globs alike).
|
||||
const PROFILE_ENV: &str = "SANDBOX_E2E_PROFILE";
|
||||
const TARGETS_ENV: &str = "SANDBOX_E2E_TARGETS";
|
||||
const CONTROLS_ENV: &str = "SANDBOX_E2E_CONTROLS";
|
||||
/// Paths NOT present at apply time that match a deny glob; the macOS runtime
|
||||
/// regex must deny creating them post-launch (the differentiator vs exact paths).
|
||||
const POSTLAUNCH_ENV: &str = "SANDBOX_E2E_POSTLAUNCH";
|
||||
const MARKER: &str = "deny-paths-e2e-marker-9f3c1a";
|
||||
|
||||
/// Re-invoke this test binary as a subprocess driving `profile` over `targets`
|
||||
/// (denied) and `controls` (must stay readable). `postlaunch` paths are created
|
||||
/// AFTER apply to exercise the macOS runtime-regex (post-launch) coverage.
|
||||
fn run_scenario(
|
||||
workspace: &Path,
|
||||
profile: &str,
|
||||
targets: &[&str],
|
||||
controls: &[&str],
|
||||
postlaunch: &[&str],
|
||||
) -> (std::process::ExitStatus, String) {
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
let output = Command::new(exe)
|
||||
.env(SCENARIO_ENV, "block_deny")
|
||||
.env(WORKSPACE_ENV, workspace.as_os_str())
|
||||
.env(PROFILE_ENV, profile)
|
||||
.env(TARGETS_ENV, targets.join(","))
|
||||
.env(CONTROLS_ENV, controls.join(","))
|
||||
.env(POSTLAUNCH_ENV, postlaunch.join(","))
|
||||
.arg("--ignored")
|
||||
.arg("--exact")
|
||||
.arg("--nocapture")
|
||||
.arg("subprocess_entry")
|
||||
.output()
|
||||
.expect("failed to spawn subprocess");
|
||||
// All assertions read stderr; the subprocess prints only diagnostics there.
|
||||
(
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Decode a comma-joined env list (empty/missing -> empty vec).
|
||||
fn list_from_env(key: &str) -> Vec<String> {
|
||||
std::env::var(key)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// EROFS too: a root writer on Linux bypasses the mode-000 DAC check via
|
||||
// CAP_DAC_OVERRIDE and hits the read-only bind-mount instead — still a denial.
|
||||
fn is_permission_denied(e: &std::io::Error) -> bool {
|
||||
matches!(
|
||||
e.raw_os_error(),
|
||||
Some(libc::EACCES) | Some(libc::EPERM) | Some(libc::EROFS)
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn a child command and `exit(1)` if its stdout exposes the secret MARKER.
|
||||
/// Asserts marker-absence rather than a non-zero exit: a root reader of the
|
||||
/// mode-000 placeholder gets empty output, which still means the path is shadowed.
|
||||
fn assert_child_cannot_read(label: &str, program: &str, args: &[&str]) {
|
||||
let out = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap_or_else(|e| panic!("failed to spawn {program}: {e}"));
|
||||
if String::from_utf8_lossy(&out.stdout).contains(MARKER) {
|
||||
eprintln!("FAIL: {label} exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert a denied file's bytes are unreadable via an in-process read, a `cat`
|
||||
/// child (the `bash`/`grep` tools), and a nested `sh -c "cat"` child (the shell a
|
||||
/// subagent shells out through). The property is MARKER-absence (EACCES/EPERM, or
|
||||
/// empty output under root, all satisfy it).
|
||||
fn assert_read_blocked(label: &str, path: &Path) {
|
||||
if let Ok(content) = fs::read_to_string(path)
|
||||
&& content.contains(MARKER)
|
||||
{
|
||||
eprintln!("FAIL: {label} in-process read exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let s = path.display().to_string();
|
||||
assert_child_cannot_read(label, "cat", &[s.as_str()]);
|
||||
let sh_cmd = format!("cat '{s}'");
|
||||
assert_child_cannot_read(label, "sh", &["-c", sh_cmd.as_str()]);
|
||||
eprintln!("OK: {label} read blocked");
|
||||
}
|
||||
|
||||
/// Assert a denied file cannot be overwritten (write must EACCES/EPERM, not
|
||||
/// succeed — a permitted write would enable the relocation bypass below).
|
||||
fn assert_write_denied(label: &str, path: &Path) {
|
||||
match fs::write(path, "overwrite-attempt") {
|
||||
Err(e) if is_permission_denied(&e) => eprintln!("OK: {label} write denied"),
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: unexpected {label} write error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(()) => {
|
||||
eprintln!("FAIL: {label} write was permitted (relocation bypass possible)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert the `mv x y && cat y` relocation bypass does not expose the bytes:
|
||||
/// the rename must fail (unlink of the source is denied) so the moved copy never
|
||||
/// materializes with the secret.
|
||||
fn assert_rename_bypass_blocked(label: &str, path: &Path, workspace: &Path) {
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
let moved = workspace.join(format!("exfil-{name}"));
|
||||
let _ = fs::rename(path, &moved); // expected to fail; bytes must not leak
|
||||
match fs::read_to_string(&moved) {
|
||||
Ok(c) if c.contains(MARKER) => {
|
||||
eprintln!("FAIL: {label} rename bypass exposed MARKER");
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => eprintln!("OK: {label} rename bypass blocked"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn bwrap_available() -> bool {
|
||||
// `--version` only checks the binary exists; remote CI may have bwrap but
|
||||
// deny user namespace creation ("Creating new namespace failed: Operation not permitted").
|
||||
Command::new("bwrap")
|
||||
.args(["--bind", "/", "/", "--", "true"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The custom profile under test, read from the env the parent set.
|
||||
fn profile_from_env() -> xai_grok_sandbox::ProfileName {
|
||||
xai_grok_sandbox::ProfileName::Custom(std::env::var(PROFILE_ENV).expect(PROFILE_ENV))
|
||||
}
|
||||
|
||||
// ── Subprocess entry point ──────────────────────────────────────────────
|
||||
|
||||
/// `#[ignore]`d — only runs when invoked by the parent test via `run_scenario`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn subprocess_entry() {
|
||||
let scenario = match std::env::var(SCENARIO_ENV) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let workspace = std::env::var(WORKSPACE_ENV).expect(WORKSPACE_ENV);
|
||||
let workspace = dunce::canonicalize(&workspace).expect("canonicalize workspace");
|
||||
let workspace = workspace.as_path();
|
||||
let targets = list_from_env(TARGETS_ENV);
|
||||
let controls = list_from_env(CONTROLS_ENV);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !xai_grok_sandbox::is_inside_bwrap() {
|
||||
// Drive the REAL routing the shell uses at startup — computing the
|
||||
// custom profile's deny set (exact paths AND launch-time glob
|
||||
// expansion), building placeholders, and failing closed on a partial
|
||||
// bind — rather than hand-rolling a single-path `bwrap_reexec_command`.
|
||||
match xai_grok_sandbox::bwrap_reexec_for_profile(&profile_from_env(), workspace) {
|
||||
Some(mut cmd) => {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = cmd.exec(); // returns only if exec failed
|
||||
eprintln!("bwrap re-exec failed: {err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Outside bwrap with no command means the read-deny set could not
|
||||
// be secured. The shell fails closed here; mirror that.
|
||||
None => {
|
||||
eprintln!("FAIL: bwrap_reexec_for_profile returned None outside bwrap");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match scenario.as_str() {
|
||||
"block_deny" => {
|
||||
let mut sandbox = xai_grok_sandbox::SandboxManager::new(profile_from_env(), workspace);
|
||||
if let Err(e) = sandbox.apply(workspace) {
|
||||
eprintln!("sandbox apply failed: {e}");
|
||||
std::process::exit(3);
|
||||
}
|
||||
if !sandbox.is_applied() {
|
||||
eprintln!("sandbox was not applied (unsupported platform?)");
|
||||
std::process::exit(4);
|
||||
}
|
||||
|
||||
// Each denied target must be read-, write-, and rename-denied — via the
|
||||
// read_file tool (in-process), `bash`/`grep` (cat child), and the shell
|
||||
// a subagent uses (sh -c child). Targets exercise nested glob matches
|
||||
// (`sub/dir/key.pem`) and the denied-directory (subpath) path alike.
|
||||
for rel in &targets {
|
||||
let path = workspace.join(rel);
|
||||
assert_read_blocked(rel, &path);
|
||||
assert_write_denied(rel, &path);
|
||||
assert_rename_bypass_blocked(rel, &path, workspace);
|
||||
}
|
||||
|
||||
// Non-denied control files (incl. a sibling of a glob match) stay readable.
|
||||
for rel in &controls {
|
||||
match fs::read_to_string(workspace.join(rel)) {
|
||||
Ok(c) if c.contains("hello") => eprintln!("OK: {rel} control readable"),
|
||||
Ok(_) => {
|
||||
eprintln!("FAIL: control {rel} readable but missing marker");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: control {rel} should stay readable: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// macOS-only: the runtime regex denies paths that match a glob even
|
||||
// when created AFTER apply — the differentiator vs the exact-path flow
|
||||
// (and the macOS-airtight half of the documented asymmetry). On Linux
|
||||
// post-launch matches are best-effort and NOT covered, so skip there.
|
||||
#[cfg(target_os = "macos")]
|
||||
for rel in list_from_env(POSTLAUNCH_ENV) {
|
||||
match fs::write(workspace.join(&rel), MARKER) {
|
||||
Err(e) if is_permission_denied(&e) => {
|
||||
eprintln!("OK: {rel} post-launch write denied")
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: unexpected {rel} post-launch write error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Ok(()) => {
|
||||
eprintln!("FAIL: {rel} post-launch matching path was writable");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A NON-matching post-launch path must still be writable — proves the
|
||||
// denial above is the glob, not a blanket workspace write-deny.
|
||||
#[cfg(target_os = "macos")]
|
||||
if !list_from_env(POSTLAUNCH_ENV).is_empty() {
|
||||
match fs::write(workspace.join("late-control.txt"), "hello") {
|
||||
Ok(()) => eprintln!("OK: post-launch control writable"),
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: non-matching post-launch path should be writable: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown scenario: {other}");
|
||||
std::process::exit(99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parent test cases ───────────────────────────────────────────────────
|
||||
|
||||
/// Drive one deny case end-to-end: define a custom profile whose `deny` list is
|
||||
/// `deny_entries` (exact paths and/or globs), create each `target` (with the
|
||||
/// MARKER) and each `control` (readable), then assert in an isolated subprocess
|
||||
/// that every target is read/write/rename-denied and every control stays
|
||||
/// readable. Shared by the exact-path and glob cases.
|
||||
fn run_deny_case(
|
||||
tag: &str,
|
||||
profile: &str,
|
||||
deny_entries: &[&str],
|
||||
targets: &[&str],
|
||||
controls: &[&str],
|
||||
postlaunch: &[&str],
|
||||
) {
|
||||
// When set, missing prerequisites must FAIL loudly instead of skipping, so a
|
||||
// CI lane can guarantee the deny enforcement is actually exercised.
|
||||
let require = std::env::var("SANDBOX_E2E_REQUIRE_ENFORCEMENT").is_ok();
|
||||
|
||||
let support = xai_grok_sandbox::SandboxManager::support_info();
|
||||
if !support.is_supported {
|
||||
if require {
|
||||
panic!(
|
||||
"SANDBOX_E2E_REQUIRE_ENFORCEMENT set but sandbox unsupported: {}",
|
||||
support.details
|
||||
);
|
||||
}
|
||||
eprintln!("skipping: sandbox not supported ({})", support.details);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if !bwrap_available() {
|
||||
if require {
|
||||
panic!(
|
||||
"SANDBOX_E2E_REQUIRE_ENFORCEMENT set but bwrap unavailable (required for Linux read-deny)"
|
||||
);
|
||||
}
|
||||
eprintln!("skipping: bwrap not installed (required for Linux read-deny)");
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = std::env::temp_dir().join(format!(
|
||||
"grok-sandbox-e2e-{tag}-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&tmp).expect("create temp workspace");
|
||||
let tmp = dunce::canonicalize(&tmp).expect("canonicalize temp workspace");
|
||||
let _cleanup = TempDirGuard(tmp.clone());
|
||||
|
||||
// Define the custom profile whose `deny` list holds the entries under test.
|
||||
let deny_list = deny_entries
|
||||
.iter()
|
||||
.map(|p| format!("\"{p}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
fs::create_dir_all(tmp.join(".grok")).expect("mkdir .grok");
|
||||
fs::write(
|
||||
tmp.join(".grok").join("sandbox.toml"),
|
||||
format!("[profiles.{profile}]\nextends = \"workspace\"\ndeny = [{deny_list}]\n"),
|
||||
)
|
||||
.expect("write sandbox.toml");
|
||||
|
||||
// Create each denied target with the MARKER (parents created as needed, e.g.
|
||||
// `sub/dir/` for a nested glob match, `secretdir/` for a denied directory)
|
||||
// plus each readable control.
|
||||
for rel in targets {
|
||||
let path = tmp.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("mkdir denied parent");
|
||||
}
|
||||
fs::write(&path, format!("SECRET={MARKER}")).expect("write denied file");
|
||||
}
|
||||
for rel in controls {
|
||||
let path = tmp.join(rel);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("mkdir control parent");
|
||||
}
|
||||
fs::write(&path, "hello workspace").expect("write control");
|
||||
}
|
||||
|
||||
let (status, stderr) = run_scenario(&tmp, profile, targets, controls, postlaunch);
|
||||
assert!(
|
||||
status.success(),
|
||||
"[{tag}] custom-profile deny should block read/write/rename\nstderr: {stderr}"
|
||||
);
|
||||
for rel in targets {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} read blocked")),
|
||||
"[{tag}] expected '{rel}' read block confirmation\nstderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} write denied")),
|
||||
"[{tag}] expected '{rel}' write to be denied\nstderr: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} rename bypass blocked")),
|
||||
"[{tag}] expected '{rel}' rename bypass to be blocked\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
for rel in controls {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} control readable")),
|
||||
"[{tag}] expected non-denied control '{rel}' to stay readable\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
// The post-launch (runtime-regex) coverage is macOS-only; Linux best-effort
|
||||
// expansion does not cover files created after launch.
|
||||
#[cfg(target_os = "macos")]
|
||||
for rel in postlaunch {
|
||||
assert!(
|
||||
stderr.contains(&format!("OK: {rel} post-launch write denied")),
|
||||
"[{tag}] expected post-launch matching '{rel}' to be write-denied\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
if !postlaunch.is_empty() {
|
||||
assert!(
|
||||
stderr.contains("OK: post-launch control writable"),
|
||||
"[{tag}] expected non-matching post-launch path to stay writable\nstderr: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_exact_paths_block_read_write_rename() {
|
||||
// Exact-path entries: two files plus a directory (exercised via a file inside
|
||||
// it), covering the literal-file and the subpath / Linux dir-placeholder paths.
|
||||
run_deny_case(
|
||||
"exact",
|
||||
"denytest",
|
||||
&[".env", "src/server.pem", "secretdir"],
|
||||
&[".env", "src/server.pem", "secretdir/inner.pem"],
|
||||
&["readable.txt"],
|
||||
&[], // exact paths have no runtime/post-launch coverage to assert
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_globs_block_read_write_rename() {
|
||||
// Glob entries exercising: nested `*.pem`, a `.env` at root AND nested, and a
|
||||
// trailing-`**` prefix dir. The control inside a matched directory
|
||||
// (`sub/dir/keep.txt`) proves the glob denies only matches, not the whole tree.
|
||||
// `postlaunch` (`late.pem`) pins the macOS runtime-regex post-launch coverage.
|
||||
run_deny_case(
|
||||
"glob",
|
||||
"denyglob",
|
||||
&["**/*.pem", "**/.env", "secrets/**"],
|
||||
&["sub/dir/key.pem", ".env", "sub/.env", "secrets/inner.key"],
|
||||
&["readable.txt", "sub/dir/keep.txt"],
|
||||
&["late.pem"],
|
||||
);
|
||||
}
|
||||
|
||||
struct TempDirGuard(std::path::PathBuf);
|
||||
|
||||
impl Drop for TempDirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
105
crates/codegen/xai-grok-sandbox/tests/integration_test.rs
Normal file
105
crates/codegen/xai-grok-sandbox/tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//! Integration tests for xai-grok-sandbox.
|
||||
//!
|
||||
//! Note: `Sandbox::apply()` is irreversible and process-wide, so we cannot
|
||||
//! test actual kernel enforcement in standard `#[test]` functions (they share
|
||||
//! a process). Use the `sandbox_smoke_test` example for enforcement testing.
|
||||
//! These tests verify the API contracts, config loading, and support detection.
|
||||
|
||||
// `support_info` is only available with the `enforce` feature (it returns a
|
||||
// nono type), so gate this test the same way.
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn test_support_info() {
|
||||
// Verify that nono can report platform support status without applying
|
||||
let support = xai_grok_sandbox::SandboxManager::support_info();
|
||||
// On macOS and Linux 5.13+, this should be supported
|
||||
// On other platforms, it gracefully reports unsupported
|
||||
println!(
|
||||
"Sandbox support: supported={}, details={}",
|
||||
support.is_supported, support.details
|
||||
);
|
||||
// We don't assert is_supported because CI may run on any platform
|
||||
}
|
||||
|
||||
// `to_capability_set` is only available with the `enforce` feature.
|
||||
#[test]
|
||||
#[cfg(all(feature = "enforce", unix))]
|
||||
fn test_profile_capability_set_construction() {
|
||||
use xai_grok_sandbox::ProfileName;
|
||||
|
||||
// Use CWD as workspace — guaranteed to exist
|
||||
let workspace = std::env::current_dir().expect("cwd");
|
||||
|
||||
// All profiles should produce valid CapabilitySets without panicking
|
||||
for profile in [
|
||||
ProfileName::Workspace,
|
||||
ProfileName::ReadOnly,
|
||||
ProfileName::Strict,
|
||||
ProfileName::Off,
|
||||
] {
|
||||
let result = profile.to_capability_set(&workspace);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Profile {:?} failed to build CapabilitySet: {:?}",
|
||||
profile,
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_manager_lifecycle() {
|
||||
use xai_grok_sandbox::{ProfileName, SandboxManager};
|
||||
|
||||
let workspace = std::env::current_dir().expect("cwd");
|
||||
|
||||
// Off profile: apply should succeed without actually sandboxing
|
||||
let mut manager = SandboxManager::new(ProfileName::Off, &workspace);
|
||||
assert!(!manager.is_applied());
|
||||
assert!(!manager.restrict_child_network());
|
||||
|
||||
let result = manager.apply(&workspace);
|
||||
assert!(result.is_ok());
|
||||
// Off profile doesn't actually apply
|
||||
assert!(!manager.is_applied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sandbox_logger() {
|
||||
use xai_grok_sandbox::{SandboxEvent, SandboxLogger};
|
||||
|
||||
let logger = SandboxLogger::new();
|
||||
|
||||
// Log some events (use violation events — profile_applied requires a resolved profile)
|
||||
logger.log(SandboxEvent::fs_violation("workspace", "/tmp/test", "read"));
|
||||
logger.log(SandboxEvent::fs_violation(
|
||||
"workspace",
|
||||
"/etc/shadow",
|
||||
"write",
|
||||
));
|
||||
logger.log(SandboxEvent::net_violation("strict", "evil.com:443"));
|
||||
|
||||
// Check metrics
|
||||
assert_eq!(logger.metrics().fs_violation_count(), 2);
|
||||
assert_eq!(logger.metrics().net_violation_count(), 1);
|
||||
|
||||
// Take events drains the buffer
|
||||
let events = logger.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
|
||||
// Buffer is now empty
|
||||
let events2 = logger.take_events();
|
||||
assert!(events2.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_restrict_child_network_default() {
|
||||
// Before any sandbox is applied, child network should not be restricted
|
||||
// Note: this test may interfere with other tests if they set the global.
|
||||
// In practice, the global is set once at process startup and never unset.
|
||||
// For testing, we just verify the default state.
|
||||
//
|
||||
// We can't meaningfully test the "set" path without applying a sandbox
|
||||
// (which is irreversible), so we verify the default is false.
|
||||
assert!(!xai_grok_sandbox::should_restrict_child_network());
|
||||
}
|
||||
Loading…
Reference in a new issue