grok-build-upstream-mirror/crates/codegen/xai-grok-sandbox/src/profiles.rs
grokkybara[bot] 98c3b2438a Synced from monorepo
Changes:
- Classify clipboard delivery confidence
- Add durable session update append
- Scope the xAI session bearer to first-party memory embedding endpoints
- Persist subagent outputs to disk and bound long-lived agent state
- Add MiniSweAgent:bash for mini-swe-agent parity
- Revert taking local sessions off the persistent shell
- Contextual tip recommending grok wrap on SSH sessions
- Voice STT bearer from model BYOK env_key/api_key
- Define exact website policies for sandbox
- Gate unsafe shell environments
- Shared pin hoist; single require_sha gate for marketplace plugins
- Server-signed is-managed claim (closes sidecar-removal downgrade)
- Optional require_sha pin for remote plugin installs
- Show session title and last exchange in the exit resume hint
- Gate shell output redirects
- Warn when fail_closed is present but not a boolean
- Add canonical text editing core (ratatui-textarea)
- Keep execution state out of goal scratch
- Add acknowledged persistence primitives
- Inherit child network restrictions in sandbox
- Fail closed when hook matchers fail to recompile
- Add MCP setup preferences for plugin MCPs
- Gate sourced shell scripts
- Gate file-typed project hooks
- grok wrap: restore terminal modes on child death
- Harden owner-only permissions on auth and MCP credentials
- Create crash dump files with owner-only permissions
- Write the agent_id cache owner-only (0600)
- SessionMetrics mode skips Mixpanel profile sync
- Dashboard: slim live-tail peek
- Yank full queued prompt text, not (+N lines)
- Defeat clock-rollback on the signed managed-config cache
- Stop early session/cancel from overtaking the prompt and wedging the turn slot
- Self-heal a diverged agent entrypoint on startup
- Add matched inference expectations in test-support
- Add AuthSingleFlight cancel/successor gap tests
- Remove consumer from external OTEL allowlist and pin scrub coverage
- Enable /copy in minimal mode
- Surface capacity and API-key detail on 429 errors
- Single-flight interactive auth
- Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00

823 lines
30 KiB
Rust

//! 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};
use crate::paths::{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(crate) fn restricts_network(&self) -> bool {
matches!(self, Self::ReadOnly | Self::Strict)
}
}
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
}
}
}
impl ProfileName {
/// Convert this profile into a nono `CapabilitySet` for the given workspace.
#[cfg(all(feature = "enforce", unix))]
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.
#[cfg(all(feature = "enforce", unix))]
pub fn to_capability_set_with_config(
&self,
workspace: &Path,
config: &SandboxConfig,
) -> anyhow::Result<CapabilitySet> {
if *self == Self::Off {
return Ok(CapabilitySet::new());
}
let profile = self.resolve_profile(workspace, config)?;
Self::capability_set_from_profile(workspace, &profile)
}
#[cfg(all(feature = "enforce", unix))]
pub(crate) fn capability_set_from_profile(
workspace: &Path,
profile: &SandboxProfile,
) -> anyhow::Result<CapabilitySet> {
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 built_in_network_restriction_values() {
let workspace = std::env::current_dir().unwrap();
let config = SandboxConfig::default();
for (name, expected) in [
(ProfileName::Workspace, false),
(ProfileName::Devbox, false),
(ProfileName::ReadOnly, true),
(ProfileName::Strict, true),
] {
let resolved = name.resolve_profile(&workspace, &config).unwrap();
assert_eq!(resolved.restrict_network, expected, "{name}");
}
}
fn network_inheritance_config() -> SandboxConfig {
SandboxConfig {
profiles: HashMap::from([
(
"strict-inherited".to_string(),
ProfileConfig {
extends: Some("strict".to_string()),
restrict_network: None,
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"read-only-inherited".to_string(),
ProfileConfig {
extends: Some("read-only".to_string()),
restrict_network: None,
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"strict-unrestricted".to_string(),
ProfileConfig {
extends: Some("strict".to_string()),
restrict_network: Some(false),
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
(
"workspace-restricted".to_string(),
ProfileConfig {
extends: Some("workspace".to_string()),
restrict_network: Some(true),
read_only: vec![],
read_write: vec![],
deny: vec![],
},
),
]),
}
}
#[test]
fn custom_network_restriction_inherits_and_overrides_base() {
let workspace = std::env::current_dir().unwrap();
let config = network_inheritance_config();
for (name, expected) in [
("strict-inherited", true),
("read-only-inherited", true),
("strict-unrestricted", false),
("workspace-restricted", true),
] {
let profile_name = ProfileName::Custom(name.to_string());
let resolved = profile_name.resolve_profile(&workspace, &config).unwrap();
assert_eq!(resolved.restrict_network, expected, "{name}");
}
}
#[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}");
}
}