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
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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue