use crate::cli::SandboxArgs;
use crate::policy;
use crate::profile::{expand_vars, Profile};
use crate::protected_paths::{self, ProtectedRoots};
use nono::{
AccessMode, CapabilitySet, CapabilitySource, FsCapability, NonoError, Result,
UnixSocketCapability, UnixSocketMode,
};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
fn try_new_dir(path: &Path, access: AccessMode, label: &str) -> Result<Option<FsCapability>> {
match FsCapability::new_dir(path, access) {
Ok(cap) => Ok(Some(cap)),
Err(NonoError::PathNotFound(_)) => {
info!("{}: {}", label, path.display());
Ok(None)
}
Err(e) => Err(e),
}
}
fn try_new_file(path: &Path, access: AccessMode, label: &str) -> Result<Option<FsCapability>> {
match FsCapability::new_file(path, access) {
Ok(cap) => Ok(Some(cap)),
Err(NonoError::PathNotFound(_)) => handle_missing_file_capability(path, access, label),
Err(e) => Err(e),
}
}
fn try_new_unix_socket_file(
path: &Path,
mode: UnixSocketMode,
label: &str,
) -> Result<Option<UnixSocketCapability>> {
match UnixSocketCapability::new_file(path, mode) {
Ok(cap) => Ok(Some(cap)),
Err(NonoError::PathNotFound(_)) => {
info!("{}: {}", label, path.display());
Ok(None)
}
Err(e) => Err(e),
}
}
fn try_new_unix_socket_dir(
path: &Path,
mode: UnixSocketMode,
label: &str,
) -> Result<Option<UnixSocketCapability>> {
match UnixSocketCapability::new_dir(path, mode) {
Ok(cap) => Ok(Some(cap)),
Err(NonoError::PathNotFound(_)) => {
info!("{}: {}", label, path.display());
Ok(None)
}
Err(e) => Err(e),
}
}
fn add_cli_unix_socket_caps(
caps: &mut CapabilitySet,
args: &SandboxArgs,
protected_roots: &ProtectedRoots,
allow_parent_of_protected: bool,
) -> Result<()> {
const LBL_SOCK_FILE: &str = "Skipping non-existent unix socket (connect grant)";
const LBL_SOCK_FILE_BIND: &str = "Skipping non-existent unix socket (connect+bind grant)";
const LBL_SOCK_DIR: &str = "Skipping non-existent unix socket directory (connect grant)";
const LBL_SOCK_DIR_BIND: &str =
"Skipping non-existent unix socket directory (connect+bind grant)";
const LBL_FS_FILE_IMPLIED: &str = "Skipping implied fs grant for non-existent unix socket";
const LBL_FS_DIR_IMPLIED: &str =
"Skipping implied fs grant for non-existent unix socket directory";
const LBL_FS_DIR_IMPLIED_BIND_PARENT: &str =
"Skipping implied fs grant on parent of pending unix socket bind path";
for path in &args.allow_unix_socket {
validate_requested_file(path, "CLI", protected_roots, allow_parent_of_protected)?;
let sock_cap = try_new_unix_socket_file(path, UnixSocketMode::Connect, LBL_SOCK_FILE)?;
if let Some(cap) = sock_cap {
caps.add_unix_socket(cap);
if let Some(cap) = try_new_file(path, AccessMode::Read, LBL_FS_FILE_IMPLIED)? {
caps.add_fs(cap);
}
}
}
for path in &args.allow_unix_socket_bind {
validate_requested_file(path, "CLI", protected_roots, allow_parent_of_protected)?;
if path.symlink_metadata().is_ok() && !path.exists() {
return Err(NonoError::SandboxInit(format!(
"connect+bind unix socket grant rejects dangling symlink \
(bind would create at the symlink's target path, which is \
not what operators usually intend): {}",
path.display()
)));
}
let sock_cap =
try_new_unix_socket_file(path, UnixSocketMode::ConnectBind, LBL_SOCK_FILE_BIND)?;
if let Some(cap) = sock_cap {
caps.add_unix_socket(cap);
if path.exists() {
if let Some(cap) = try_new_file(path, AccessMode::ReadWrite, LBL_FS_FILE_IMPLIED)? {
caps.add_fs(cap);
}
} else if let Some(parent) = path.parent() {
if let Some(cap) = try_new_dir(
parent,
AccessMode::ReadWrite,
LBL_FS_DIR_IMPLIED_BIND_PARENT,
)? {
caps.add_fs(cap);
}
}
}
}
for path in &args.allow_unix_socket_dir {
validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
let sock_cap = try_new_unix_socket_dir(path, UnixSocketMode::Connect, LBL_SOCK_DIR)?;
if let Some(cap) = sock_cap {
caps.add_unix_socket(cap);
if let Some(cap) = try_new_dir(path, AccessMode::Read, LBL_FS_DIR_IMPLIED)? {
caps.add_fs(cap);
}
}
}
for path in &args.allow_unix_socket_dir_bind {
validate_requested_dir(path, "CLI", protected_roots, allow_parent_of_protected)?;
let sock_cap =
try_new_unix_socket_dir(path, UnixSocketMode::ConnectBind, LBL_SOCK_DIR_BIND)?;
if let Some(cap) = sock_cap {
caps.add_unix_socket(cap);
if let Some(cap) = try_new_dir(path, AccessMode::ReadWrite, LBL_FS_DIR_IMPLIED)? {
caps.add_fs(cap);
}
}
}
Ok(())
}
fn try_new_profile_exact_path(
path: &Path,
access: AccessMode,
label: &str,
protected_roots: &ProtectedRoots,
allow_parent_of_protected: bool,
) -> Result<Option<FsCapability>> {
validate_requested_file(path, "Profile", protected_roots, allow_parent_of_protected)?;
match try_new_file(path, access, label) {
Err(NonoError::ExpectedFile(_)) => {
handle_exact_directory_path(path, access, protected_roots, allow_parent_of_protected)
}
result => result,
}
}
#[cfg(target_os = "macos")]
fn handle_exact_directory_path(
path: &Path,
access: AccessMode,
protected_roots: &ProtectedRoots,
allow_parent_of_protected: bool,
) -> Result<Option<FsCapability>> {
validate_requested_dir(path, "Profile", protected_roots, allow_parent_of_protected)?;
let resolved = path.canonicalize().map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
NonoError::PathNotFound(path.to_path_buf())
} else {
NonoError::PathCanonicalization {
path: path.to_path_buf(),
source,
}
}
})?;
debug!(
"Profile exact-file path resolved as directory; granting exact macOS literal path access: {}",
path.display()
);
Ok(Some(FsCapability {
original: path.to_path_buf(),
resolved,
access,
is_file: true,
source: CapabilitySource::Profile,
}))
}
#[cfg(not(target_os = "macos"))]
fn handle_exact_directory_path(
path: &Path,
_access: AccessMode,
_protected_roots: &ProtectedRoots,
_allow_parent_of_protected: bool,
) -> Result<Option<FsCapability>> {
Err(NonoError::ExpectedFile(path.to_path_buf()))
}
#[cfg(target_os = "macos")]
fn handle_missing_file_capability(
path: &Path,
access: AccessMode,
_label: &str,
) -> Result<Option<FsCapability>> {
let cap = new_future_file_capability(path, access)?;
debug!(
"Granting future exact file capability on macOS for missing path: {}",
path.display()
);
Ok(Some(cap))
}
#[cfg(not(target_os = "macos"))]
fn handle_missing_file_capability(
path: &Path,
_access: AccessMode,
label: &str,
) -> Result<Option<FsCapability>> {
info!("{}: {}", label, path.display());
Ok(None)
}
#[cfg(target_os = "macos")]
fn new_future_file_capability(path: &Path, access: AccessMode) -> Result<FsCapability> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(NonoError::Io)?.join(path)
};
Ok(FsCapability {
original: path.to_path_buf(),
resolved: resolve_missing_leaf_path(&absolute)?,
access,
is_file: true,
source: CapabilitySource::User,
})
}
#[cfg(target_os = "macos")]
fn resolve_missing_leaf_path(path: &Path) -> Result<PathBuf> {
for ancestor in path.ancestors() {
match ancestor.canonicalize() {
Ok(mut canonical) => {
if let Ok(relative) = path.strip_prefix(ancestor) {
canonical.push(relative);
}
return Ok(canonical);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => {
return Err(NonoError::PathCanonicalization {
path: path.to_path_buf(),
source: err,
});
}
}
}
Err(NonoError::PathNotFound(path.to_path_buf()))
}
#[cfg(target_os = "macos")]
fn add_atomic_write_rule(caps: &mut CapabilitySet, cap: &FsCapability) -> Result<()> {
if !matches!(cap.access, AccessMode::Write | AccessMode::ReadWrite) {
return Ok(());
}
fn add_rule_for_path(caps: &mut CapabilitySet, path: &Path) -> Result<()> {
let path_str = path.to_str().ok_or_else(|| {
NonoError::SandboxInit(format!(
"non-UTF-8 path for atomic write rule: {}",
path.display()
))
})?;
let escaped = regex_escape_path(path_str);
let rule = format!(
"(allow file-write* (regex #\"^{}\\.tmp\\.[0-9]+\\.[0-9]+$\"))",
escaped
);
caps.add_platform_rule(&rule)
}
add_rule_for_path(caps, &cap.resolved)?;
if cap.original != cap.resolved {
add_rule_for_path(caps, &cap.original)?;
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn add_atomic_write_rule(_caps: &mut CapabilitySet, _cap: &FsCapability) -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
fn regex_escape_path(path: &str) -> String {
let mut out = String::with_capacity(path.len() + 8);
for c in path.chars() {
match c {
'.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
out
}
fn apply_profile_dir_allows(
path_templates: &[String],
access: AccessMode,
workdir: &Path,
protected_roots: &ProtectedRoots,
caps: &mut CapabilitySet,
label_prefix: &str,
allow_parent_of_protected: bool,
) -> Result<()> {
for path_template in path_templates {
let path = expand_vars(path_template, workdir)?;
validate_requested_dir(&path, "Profile", protected_roots, allow_parent_of_protected)?;
let label = format!(
"{label_prefix} '{}' does not exist, skipping",
path_template
);
if let Some(mut cap) = try_new_dir(&path, access, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
Ok(())
}
fn validate_requested_dir(
path: &Path,
source: &str,
protected_roots: &ProtectedRoots,
allow_parent_of_protected: bool,
) -> Result<()> {
protected_paths::validate_requested_path_against_protected_roots(
path,
false,
source,
protected_roots.as_paths(),
allow_parent_of_protected,
)
}
fn validate_requested_file(
path: &Path,
source: &str,
protected_roots: &ProtectedRoots,
allow_parent_of_protected: bool,
) -> Result<()> {
protected_paths::validate_requested_path_against_protected_roots(
path,
true,
source,
protected_roots.as_paths(),
allow_parent_of_protected,
)
}
pub(crate) fn default_profile_groups() -> Result<Vec<String>> {
let profile = crate::policy::get_policy_profile("default")?
.ok_or_else(|| NonoError::ProfileNotFound("default".to_string()))?;
Ok(profile.security.groups)
}
#[must_use]
pub(crate) fn retains_missing_exact_file_grants() -> bool {
cfg!(target_os = "macos")
}
pub trait CapabilitySetExt {
fn from_args(args: &SandboxArgs) -> Result<(CapabilitySet, bool)>;
fn from_profile(
profile: &Profile,
workdir: &Path,
args: &SandboxArgs,
) -> Result<(CapabilitySet, bool)>;
}
impl CapabilitySetExt for CapabilitySet {
fn from_args(args: &SandboxArgs) -> Result<(CapabilitySet, bool)> {
let mut caps = CapabilitySet::new();
let protected_roots = ProtectedRoots::from_defaults()?;
let loaded_policy = policy::load_embedded_policy()?;
let default_groups = default_profile_groups()?;
let mut resolved = policy::resolve_groups(&loaded_policy, &default_groups, &mut caps)?;
for path in &args.allow {
validate_requested_dir(path, "CLI", &protected_roots, false)?;
if let Some(cap) =
try_new_dir(path, AccessMode::ReadWrite, "Skipping non-existent path")?
{
caps.add_fs(cap);
}
}
for path in &args.read {
validate_requested_dir(path, "CLI", &protected_roots, false)?;
if let Some(cap) = try_new_dir(path, AccessMode::Read, "Skipping non-existent path")? {
caps.add_fs(cap);
}
}
for path in &args.write {
validate_requested_dir(path, "CLI", &protected_roots, false)?;
if let Some(cap) = try_new_dir(path, AccessMode::Write, "Skipping non-existent path")? {
caps.add_fs(cap);
}
}
for path in &args.allow_file {
validate_requested_file(path, "CLI", &protected_roots, false)?;
if let Some(cap) =
try_new_file(path, AccessMode::ReadWrite, "Skipping non-existent file")?
{
caps.add_fs(cap);
}
}
for path in &args.read_file {
validate_requested_file(path, "CLI", &protected_roots, false)?;
if let Some(cap) = try_new_file(path, AccessMode::Read, "Skipping non-existent file")? {
caps.add_fs(cap);
}
}
for path in &args.write_file {
validate_requested_file(path, "CLI", &protected_roots, false)?;
if let Some(cap) = try_new_file(path, AccessMode::Write, "Skipping non-existent file")?
{
caps.add_fs(cap);
}
}
add_cli_unix_socket_caps(&mut caps, args, &protected_roots, false)?;
apply_cli_network_mode(&mut caps, args);
for port in &args.allow_port {
caps.add_localhost_port(*port);
}
#[cfg(target_os = "macos")]
if !args.allow_connect_port.is_empty() {
return Err(NonoError::UnsupportedPlatform(
"--allow-connect-port is not supported on macOS: Seatbelt cannot filter by TCP port. \
Use --allow-domain for host-level filtering, or ProxyOnly mode."
.to_string(),
));
}
#[cfg(not(target_os = "macos"))]
for port in &args.allow_connect_port {
caps.add_tcp_connect_port(*port);
}
for cmd in &args.allow_command {
caps.add_allowed_command(cmd.clone());
}
for cmd in &args.block_command {
caps.add_blocked_command(cmd);
}
finalize_caps(&mut caps, &mut resolved, &loaded_policy, args, &[])?;
Ok((caps, resolved.needs_unlink_overrides))
}
fn from_profile(
profile: &Profile,
workdir: &Path,
args: &SandboxArgs,
) -> Result<(CapabilitySet, bool)> {
let mut caps = CapabilitySet::new();
let protected_roots = ProtectedRoots::from_defaults()?;
let allow_parent_of_protected = profile.allow_parent_of_protected.unwrap_or(false);
let loaded_policy = policy::load_embedded_policy()?;
let groups = profile.security.groups.clone();
let mut resolved = policy::resolve_groups(&loaded_policy, &groups, &mut caps)?;
debug!("Resolved {} policy groups", resolved.names.len());
let fs = &profile.filesystem;
for path_template in &fs.allow {
let path = expand_vars(path_template, workdir)?;
validate_requested_dir(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
let label = format!("Profile path '{}' does not exist, skipping", path_template);
if let Some(mut cap) = try_new_dir(&path, AccessMode::ReadWrite, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.read {
let path = expand_vars(path_template, workdir)?;
let label = format!("Profile path '{}' does not exist, skipping", path_template);
let reads_file = std::fs::metadata(&path)
.map(|metadata| !metadata.is_dir())
.unwrap_or(false);
let maybe_cap = if reads_file {
validate_requested_file(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
try_new_file(&path, AccessMode::Read, &label)?
} else {
validate_requested_dir(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
try_new_dir(&path, AccessMode::Read, &label)?
};
if let Some(mut cap) = maybe_cap {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.write {
let path = expand_vars(path_template, workdir)?;
validate_requested_dir(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
let label = format!("Profile path '{}' does not exist, skipping", path_template);
if let Some(mut cap) = try_new_dir(&path, AccessMode::Write, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.allow_file {
let path = expand_vars(path_template, workdir)?;
let label = format!("Profile file '{}' does not exist, skipping", path_template);
if let Some(mut cap) = try_new_profile_exact_path(
&path,
AccessMode::ReadWrite,
&label,
&protected_roots,
allow_parent_of_protected,
)? {
add_atomic_write_rule(&mut caps, &cap)?;
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.read_file {
let path = expand_vars(path_template, workdir)?;
let label = format!("Profile file '{}' does not exist, skipping", path_template);
if let Some(mut cap) = try_new_profile_exact_path(
&path,
AccessMode::Read,
&label,
&protected_roots,
allow_parent_of_protected,
)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.write_file {
let path = expand_vars(path_template, workdir)?;
let label = format!("Profile file '{}' does not exist, skipping", path_template);
if let Some(mut cap) = try_new_profile_exact_path(
&path,
AccessMode::Write,
&label,
&protected_roots,
allow_parent_of_protected,
)? {
add_atomic_write_rule(&mut caps, &cap)?;
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
for path_template in &fs.unix_socket {
let path = expand_vars(path_template, workdir)?;
validate_requested_file(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
let label = format!(
"Profile unix socket '{}' does not exist, skipping",
path_template
);
if let Some(mut cap) = try_new_unix_socket_file(&path, UnixSocketMode::Connect, &label)?
{
cap.source = CapabilitySource::Profile;
caps.add_unix_socket(cap);
if let Some(mut cap) = try_new_file(&path, AccessMode::Read, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
}
for path_template in &fs.unix_socket_bind {
let path = expand_vars(path_template, workdir)?;
validate_requested_file(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
if path.symlink_metadata().is_ok() && !path.exists() {
return Err(NonoError::SandboxInit(format!(
"Profile unix_socket_bind rejects dangling symlink \
(bind would punch through to the link target): '{}'",
path_template
)));
}
let label = format!(
"Profile unix socket '{}' does not exist, skipping",
path_template
);
if let Some(mut cap) =
try_new_unix_socket_file(&path, UnixSocketMode::ConnectBind, &label)?
{
cap.source = CapabilitySource::Profile;
caps.add_unix_socket(cap);
if path.exists() {
if let Some(mut cap) = try_new_file(&path, AccessMode::ReadWrite, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
} else if let Some(parent) = path.parent() {
if let Some(mut cap) = try_new_dir(parent, AccessMode::ReadWrite, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
}
}
for path_template in &fs.unix_socket_dir {
let path = expand_vars(path_template, workdir)?;
validate_requested_dir(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
let label = format!(
"Profile unix socket dir '{}' does not exist, skipping",
path_template
);
if let Some(mut cap) = try_new_unix_socket_dir(&path, UnixSocketMode::Connect, &label)?
{
cap.source = CapabilitySource::Profile;
caps.add_unix_socket(cap);
if let Some(mut cap) = try_new_dir(&path, AccessMode::Read, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
}
for path_template in &fs.unix_socket_dir_bind {
let path = expand_vars(path_template, workdir)?;
validate_requested_dir(
&path,
"Profile",
&protected_roots,
allow_parent_of_protected,
)?;
let label = format!(
"Profile unix socket dir '{}' does not exist, skipping",
path_template
);
if let Some(mut cap) =
try_new_unix_socket_dir(&path, UnixSocketMode::ConnectBind, &label)?
{
cap.source = CapabilitySource::Profile;
caps.add_unix_socket(cap);
if let Some(mut cap) = try_new_dir(&path, AccessMode::ReadWrite, &label)? {
cap.source = CapabilitySource::Profile;
caps.add_fs(cap);
}
}
}
apply_profile_dir_allows(
&profile.policy.add_allow_readwrite,
AccessMode::ReadWrite,
workdir,
&protected_roots,
&mut caps,
"Profile policy path",
allow_parent_of_protected,
)?;
apply_profile_dir_allows(
&profile.policy.add_allow_read,
AccessMode::Read,
workdir,
&protected_roots,
&mut caps,
"Profile policy path",
allow_parent_of_protected,
)?;
apply_profile_dir_allows(
&profile.policy.add_allow_write,
AccessMode::Write,
workdir,
&protected_roots,
&mut caps,
"Profile policy path",
allow_parent_of_protected,
)?;
for path_template in &profile.policy.add_deny_access {
let path = expand_vars(path_template, workdir)?;
let path_str = path.to_str().ok_or_else(|| {
NonoError::ConfigParse(format!(
"Profile policy deny path contains non-UTF-8 bytes: {}",
path.display()
))
})?;
policy::add_deny_access_rules(path_str, &mut caps, &mut resolved.deny_paths)?;
}
for cmd in &profile.policy.add_deny_commands {
caps.add_blocked_command(cmd);
}
if profile.network.block {
caps.set_network_blocked(true);
} else if profile.network.has_proxy_flags() {
let bind_ports =
crate::merge_dedup_ports(&profile.network.listen_port, &args.allow_bind);
caps = caps.set_network_mode(nono::NetworkMode::ProxyOnly {
port: 0,
bind_ports,
});
}
for port in &profile.network.open_port {
caps.add_localhost_port(*port);
}
#[cfg(target_os = "macos")]
if !profile.network.connect_port.is_empty() {
return Err(NonoError::UnsupportedPlatform(
"profile `connect_port` is not supported on macOS: Seatbelt cannot filter by TCP \
port. Use `allow_domain` for host-level filtering, or ProxyOnly mode."
.to_string(),
));
}
#[cfg(not(target_os = "macos"))]
for port in &profile.network.connect_port {
caps.add_tcp_connect_port(*port);
}
for cmd in &profile.security.allowed_commands {
caps.add_allowed_command(cmd.as_str());
}
let mode = profile
.security
.signal_mode
.map(nono::SignalMode::from)
.unwrap_or_default();
caps = caps.set_signal_mode(mode);
let process_info_mode = profile
.security
.process_info_mode
.map(nono::ProcessInfoMode::from)
.unwrap_or_default();
caps.set_process_info_mode_mut(process_info_mode);
let ipc_mode = profile
.security
.ipc_mode
.map(nono::IpcMode::from)
.unwrap_or_default();
caps.set_ipc_mode_mut(ipc_mode);
add_cli_overrides(&mut caps, args, allow_parent_of_protected)?;
let mut profile_overrides = Vec::with_capacity(profile.policy.override_deny.len());
for path_template in &profile.policy.override_deny {
let path = expand_vars(path_template, workdir)?;
if path.exists() {
profile_overrides.push(path);
}
}
finalize_caps(
&mut caps,
&mut resolved,
&loaded_policy,
args,
&profile_overrides,
)?;
Ok((caps, resolved.needs_unlink_overrides))
}
}
fn finalize_caps(
caps: &mut CapabilitySet,
resolved: &mut policy::ResolvedGroups,
_loaded_policy: &policy::Policy,
args: &SandboxArgs,
profile_override_deny: &[PathBuf],
) -> Result<()> {
policy::apply_deny_overrides(profile_override_deny, &mut resolved.deny_paths, caps)?;
policy::apply_deny_overrides(&args.override_deny, &mut resolved.deny_paths, caps)?;
caps.remove_exact_file_caps_for_paths(&resolved.deny_paths);
policy::validate_deny_overlaps(&resolved.deny_paths, caps)?;
policy::apply_macos_keychain_db_exception(caps);
caps.deduplicate();
Ok(())
}
fn apply_cli_network_mode(caps: &mut CapabilitySet, args: &SandboxArgs) {
if args.block_net {
caps.set_network_blocked(true);
} else if args.allow_net {
caps.set_network_mode_mut(nono::NetworkMode::AllowAll);
} else if args.has_proxy_flags() {
caps.set_network_mode_mut(nono::NetworkMode::ProxyOnly {
port: 0,
bind_ports: args.allow_bind.clone(),
});
}
}
fn add_cli_overrides(
caps: &mut CapabilitySet,
args: &SandboxArgs,
allow_parent_of_protected: bool,
) -> Result<()> {
let protected_roots = ProtectedRoots::from_defaults()?;
for path in &args.allow {
validate_requested_dir(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_dir(path, AccessMode::ReadWrite, "Skipping non-existent path")? {
caps.add_fs(cap);
}
}
for path in &args.read {
validate_requested_dir(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_dir(path, AccessMode::Read, "Skipping non-existent path")? {
caps.add_fs(cap);
}
}
for path in &args.write {
validate_requested_dir(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_dir(path, AccessMode::Write, "Skipping non-existent path")? {
caps.add_fs(cap);
}
}
for path in &args.allow_file {
validate_requested_file(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_file(path, AccessMode::ReadWrite, "Skipping non-existent file")?
{
caps.add_fs(cap);
}
}
for path in &args.read_file {
validate_requested_file(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_file(path, AccessMode::Read, "Skipping non-existent file")? {
caps.add_fs(cap);
}
}
for path in &args.write_file {
validate_requested_file(path, "CLI", &protected_roots, allow_parent_of_protected)?;
if let Some(cap) = try_new_file(path, AccessMode::Write, "Skipping non-existent file")? {
caps.add_fs(cap);
}
}
add_cli_unix_socket_caps(caps, args, &protected_roots, allow_parent_of_protected)?;
apply_cli_network_mode(caps, args);
for port in &args.allow_port {
caps.add_localhost_port(*port);
}
#[cfg(target_os = "macos")]
if !args.allow_connect_port.is_empty() {
return Err(NonoError::UnsupportedPlatform(
"--allow-connect-port is not supported on macOS: Seatbelt cannot filter by TCP port. \
Use --allow-domain for host-level filtering, or ProxyOnly mode."
.to_string(),
));
}
#[cfg(not(target_os = "macos"))]
for port in &args.allow_connect_port {
caps.add_tcp_connect_port(*port);
}
for cmd in &args.allow_command {
caps.add_allowed_command(cmd.clone());
}
for cmd in &args.block_command {
caps.add_blocked_command(cmd);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn with_env_lock<T>(f: impl FnOnce() -> T) -> T {
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f()
}
fn from_args_locked(args: &SandboxArgs) -> Result<(CapabilitySet, bool)> {
with_env_lock(|| CapabilitySet::from_args(args))
}
fn from_profile_locked(
profile: &crate::profile::Profile,
workdir: &Path,
args: &SandboxArgs,
) -> Result<(CapabilitySet, bool)> {
with_env_lock(|| CapabilitySet::from_profile(profile, workdir, args))
}
fn sandbox_args() -> SandboxArgs {
SandboxArgs::default()
}
#[test]
fn test_from_args_basic() {
let dir = tempdir().expect("Failed to create temp dir");
let args = SandboxArgs {
allow: vec![dir.path().to_path_buf()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("Failed to build caps");
assert!(caps.has_fs());
assert!(!caps.is_network_blocked());
}
#[test]
fn test_from_args_network_blocked() {
let args = SandboxArgs {
block_net: true,
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("Failed to build caps");
assert!(caps.is_network_blocked());
}
#[test]
fn test_from_args_with_commands() {
let args = SandboxArgs {
override_deny: vec![],
allow_command: vec!["rm".to_string()],
block_command: vec!["custom".to_string()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("Failed to build caps");
assert!(caps.allowed_commands().contains(&"rm".to_string()));
assert!(caps.blocked_commands().contains(&"custom".to_string()));
}
#[test]
fn test_from_args_rejects_protected_state_subtree() {
with_env_lock(|| {
let home = dirs::home_dir().expect("home");
let protected_subtree = home.join(".nono").join("rollbacks");
let args = SandboxArgs {
allow: vec![protected_subtree],
..sandbox_args()
};
let err =
CapabilitySet::from_args(&args).expect_err("must reject protected state path");
assert!(
err.to_string()
.contains("overlaps protected nono state root"),
"unexpected error: {err}",
);
});
}
#[test]
fn test_from_args_uses_default_profile_groups_for_runtime_policy() {
with_env_lock(|| {
let args = sandbox_args();
let (caps, _) = CapabilitySet::from_args(&args).expect("build caps from args");
let policy = crate::policy::load_embedded_policy().expect("load embedded policy");
let default_groups = default_profile_groups().expect("get default profile groups");
let deny_paths = crate::policy::resolve_deny_paths_for_groups(&policy, &default_groups)
.expect("resolve deny paths");
crate::policy::validate_deny_overlaps(&deny_paths, &caps)
.expect("from_args caps should match default profile deny policy");
});
}
#[cfg(target_os = "linux")]
#[test]
fn test_from_args_skips_linux_temp_root_when_home_is_nested() {
let temp_root = tempdir().expect("tmpdir").keep();
let home = temp_root.join("home");
let allowed = temp_root.join("other");
std::fs::create_dir_all(&home).expect("create home");
std::fs::create_dir_all(&allowed).expect("create allowed dir");
let _guard = match crate::test_env::ENV_LOCK.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
let home_str = home.to_string_lossy().into_owned();
let tmpdir_str = temp_root.to_string_lossy().into_owned();
let _env = crate::test_env::EnvVarGuard::set_all(&[
("HOME", home_str.as_str()),
("TMPDIR", tmpdir_str.as_str()),
]);
let args = SandboxArgs {
allow: vec![allowed.clone()],
..sandbox_args()
};
let result = CapabilitySet::from_args(&args);
let (caps, _) = result.expect(
"from_args should succeed when HOME is nested under TMPDIR and the user grants a sibling path",
);
let allowed_canonical = allowed.canonicalize().expect("canonicalize allowed dir");
assert!(
caps.fs_capabilities()
.iter()
.any(|cap| !cap.is_file && cap.resolved == allowed_canonical),
"explicit user grant under TMPDIR should still be present"
);
}
#[test]
fn test_from_profile_allowed_commands() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("rm-test.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "rm-test" },
"filesystem": { "allow": ["/tmp"] },
"security": { "allowed_commands": ["rm", "shred"] }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.allowed_commands().contains(&"rm".to_string()),
"profile allowed_commands should include 'rm'"
);
assert!(
caps.allowed_commands().contains(&"shred".to_string()),
"profile allowed_commands should include 'shred'"
);
}
#[test]
fn test_from_profile_filesystem_read_accepts_file_paths() {
let dir = tempdir().expect("tmpdir");
let read_file = dir.path().join("config.txt");
std::fs::write(&read_file, "token=123").expect("write file");
let profile_path = dir.path().join("read-file-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "read-file-profile" }},
"filesystem": {{ "read": ["{}"] }}
}}"#,
read_file.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let resolved_file = read_file.canonicalize().expect("canonicalize file");
assert!(
caps.fs_capabilities().iter().any(|cap| {
cap.is_file && cap.access == AccessMode::Read && cap.resolved == resolved_file
}),
"filesystem.read file entries should be granted as read-only file capabilities"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_profile_allow_file_keeps_missing_exact_file_on_macos() {
let dir = tempdir().expect("tmpdir");
let missing_file = dir.path().join("future.lock");
let expected_resolved = dir.path().canonicalize().expect("canonicalize dir").join(
missing_file
.file_name()
.expect("future file should have file name"),
);
let profile_path = dir.path().join("missing-file-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "missing-file-profile" }},
"filesystem": {{ "allow_file": ["{}"] }}
}}"#,
missing_file.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.fs_capabilities().iter().any(|cap| {
cap.is_file
&& cap.access == AccessMode::ReadWrite
&& cap.original == missing_file
&& cap.resolved == expected_resolved
}),
"macOS profiles should preserve explicit missing exact-file grants"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_args_allow_file_resolves_parent_symlinks_for_missing_file_on_macos() {
let dir = tempdir().expect("tmpdir");
let target_dir = dir.path().join("target");
let link_dir = dir.path().join("link");
std::fs::create_dir_all(&target_dir).expect("create target dir");
std::os::unix::fs::symlink(&target_dir, &link_dir).expect("create symlink");
let missing_file = link_dir.join("future.lock");
let resolved_file = target_dir
.canonicalize()
.expect("canonicalize target dir")
.join("future.lock");
let args = SandboxArgs {
allow_file: vec![missing_file.clone()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("build caps");
assert!(
caps.fs_capabilities().iter().any(|cap| {
cap.is_file
&& cap.access == AccessMode::ReadWrite
&& cap.original == missing_file
&& cap.resolved == resolved_file
}),
"macOS CLI exact-file grants should preserve original path and resolve parent symlinks"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_profile_allow_file_falls_back_to_exact_directory_when_present() {
let dir = tempdir().expect("tmpdir");
let lock_dir = dir.path().join("claude.lock");
std::fs::create_dir_all(&lock_dir).expect("create lock dir");
let resolved_dir = lock_dir.canonicalize().expect("canonicalize lock dir");
let child = lock_dir.join("nested.txt");
let profile_path = dir.path().join("lock-dir-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "lock-dir-profile" }},
"filesystem": {{ "allow_file": ["{}"] }}
}}"#,
lock_dir.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.fs_capabilities().iter().any(|cap| {
cap.is_file
&& cap.access == AccessMode::ReadWrite
&& cap.original == lock_dir
&& cap.resolved == resolved_dir
}),
"macOS profiles should preserve exact-path semantics when an allow_file entry resolves to a directory"
);
assert!(
!caps.path_covered(&child),
"exact-path fallback must not recursively cover descendants"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn test_from_profile_allow_file_rejects_directory_when_exact_dir_unsupported() {
let dir = tempdir().expect("tmpdir");
let lock_dir = dir.path().join("claude.lock");
std::fs::create_dir_all(&lock_dir).expect("create lock dir");
let profile_path = dir.path().join("lock-dir-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "lock-dir-profile" }},
"filesystem": {{ "allow_file": ["{}"] }}
}}"#,
lock_dir.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let err = from_profile_locked(&profile, workdir.path(), &args).expect_err("should fail");
assert!(
matches!(err, NonoError::ExpectedFile(ref p) if p == &lock_dir),
"expected exact-file entries resolving to directories to fail closed, got: {err}"
);
}
#[test]
fn test_from_profile_policy_exclude_groups_removes_non_required_group() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("exclude-groups.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "exclude-groups" },
"filesystem": { "allow": ["/tmp"] },
"policy": {
"exclude_groups": ["dangerous_commands", "dangerous_commands_linux"]
}
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
!caps.blocked_commands().contains(&"rm".to_string()),
"excluded dangerous_commands should remove rm from blocked commands"
);
assert!(
!caps.blocked_commands().contains(&"shred".to_string()),
"excluded dangerous_commands_linux should remove shred from blocked commands"
);
}
#[test]
fn test_from_loaded_profile_extends_default_respects_excluded_blocked_commands() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("no-dangerous-commands.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "no-dangerous-commands", "version": "1.0.0" },
"extends": "default",
"policy": {
"exclude_groups": [
"dangerous_commands",
"dangerous_commands_linux",
"dangerous_commands_macos"
]
},
"workdir": { "access": "readwrite" }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
!caps.blocked_commands().contains(&"rm".to_string()),
"excluded dangerous_commands should remove rm from blocked commands"
);
assert!(
!caps.blocked_commands().contains(&"shred".to_string()),
"excluded dangerous_commands_linux should remove shred from blocked commands"
);
}
#[test]
fn test_from_profile_policy_add_allow_paths_add_capabilities() {
let dir = tempdir().expect("tmpdir");
let read_dir = dir.path().join("read-dir");
let write_dir = dir.path().join("write-dir");
let rw_dir = dir.path().join("rw-dir");
std::fs::create_dir_all(&read_dir).expect("mkdir read");
std::fs::create_dir_all(&write_dir).expect("mkdir write");
std::fs::create_dir_all(&rw_dir).expect("mkdir rw");
let profile_path = dir.path().join("policy-adds.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-adds" }},
"policy": {{
"add_allow_read": ["{}"],
"add_allow_write": ["{}"],
"add_allow_readwrite": ["{}"]
}}
}}"#,
read_dir.display(),
write_dir.display(),
rw_dir.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let read_canonical = read_dir.canonicalize().expect("canonicalize read");
let write_canonical = write_dir.canonicalize().expect("canonicalize write");
let rw_canonical = rw_dir.canonicalize().expect("canonicalize rw");
let read_cap = caps
.fs_capabilities()
.iter()
.find(|c| c.resolved == read_canonical)
.expect("read dir cap");
let write_cap = caps
.fs_capabilities()
.iter()
.find(|c| c.resolved == write_canonical)
.expect("write dir cap");
let rw_cap = caps
.fs_capabilities()
.iter()
.find(|c| c.resolved == rw_canonical)
.expect("rw dir cap");
assert_eq!(read_cap.access, AccessMode::Read);
assert_eq!(write_cap.access, AccessMode::Write);
assert_eq!(rw_cap.access, AccessMode::ReadWrite);
}
#[cfg(target_os = "linux")]
#[test]
fn test_from_profile_policy_add_deny_access_participates_in_overlap_validation() {
let dir = tempdir().expect("tmpdir");
let allowed = dir.path().join("allowed");
let denied = allowed.join("child");
std::fs::create_dir_all(&denied).expect("mkdir denied child");
let profile_path = dir.path().join("policy-deny.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-deny" }},
"policy": {{
"add_allow_readwrite": ["{}"],
"add_deny_access": ["{}"]
}}
}}"#,
allowed.display(),
denied.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let err = from_profile_locked(&profile, workdir.path(), &args)
.expect_err("profile deny overlap should fail on linux");
assert!(
err.to_string().contains("Landlock deny-overlap"),
"unexpected error: {err}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn test_from_profile_policy_add_deny_access_tracks_symlink_target_for_overlap_validation() {
let dir = tempdir().expect("tmpdir");
let target_dir = dir.path().join("target");
let denied_target = target_dir.join("child");
std::fs::create_dir_all(&denied_target).expect("mkdir denied target");
let symlink_dir = dir.path().join("symlinked");
std::os::unix::fs::symlink(&denied_target, &symlink_dir).expect("create symlink");
let profile_path = dir.path().join("policy-deny-symlink.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-deny-symlink" }},
"policy": {{
"add_allow_readwrite": ["{}"],
"add_deny_access": ["{}"]
}}
}}"#,
target_dir.display(),
symlink_dir.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let err = from_profile_locked(&profile, workdir.path(), &args)
.expect_err("symlinked deny overlap should fail on linux");
assert!(
err.to_string().contains("Landlock deny-overlap"),
"unexpected error: {err}"
);
}
#[test]
fn test_from_profile_policy_add_deny_access_removes_symlinked_file_grant() {
let dir = tempdir().expect("tmpdir");
let target = dir.path().join("real_gitconfig");
std::fs::write(&target, "[user]\n").expect("write target");
let target_canonical = target.canonicalize().expect("canonicalize target");
let link = dir.path().join(".gitconfig");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let profile_path = dir.path().join("policy-deny-file-symlink.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-deny-file-symlink" }},
"filesystem": {{
"read_file": ["{}"]
}},
"policy": {{
"exclude_groups": ["system_read_linux", "system_write_linux"],
"add_deny_access": ["{}"]
}}
}}"#,
target.display(),
link.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
!caps
.fs_capabilities()
.iter()
.any(|cap| cap.is_file && cap.resolved == target_canonical),
"deny patch should remove the inherited file grant for the symlink target"
);
}
#[test]
fn test_from_profile_policy_add_deny_access_respects_override_deny_for_symlinked_file() {
let dir = tempdir().expect("tmpdir");
let target = dir.path().join("real_gitconfig");
std::fs::write(&target, "[user]\n").expect("write target");
let target_canonical = target.canonicalize().expect("canonicalize target");
let link = dir.path().join(".gitconfig");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let profile_path = dir.path().join("policy-deny-file-symlink-override.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-deny-file-symlink-override" }},
"filesystem": {{
"read_file": ["{}"]
}},
"policy": {{
"exclude_groups": ["system_read_linux", "system_write_linux"],
"add_deny_access": ["{}"]
}}
}}"#,
target.display(),
link.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let mut args = sandbox_args();
args.override_deny = vec![target.clone()];
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.fs_capabilities()
.iter()
.any(|cap| cap.is_file && cap.resolved == target_canonical),
"override should preserve the inherited file grant for the denied symlink target"
);
}
#[test]
fn test_from_profile_policy_override_deny_via_symlink_path() {
let dir = tempdir().expect("tmpdir");
let target = dir.path().join("real_gitconfig");
std::fs::write(&target, "[user]\n").expect("write target");
let target_canonical = target.canonicalize().expect("canonicalize target");
let link = dir.path().join(".gitconfig");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let profile_path = dir.path().join("override-deny-symlink.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "override-deny-symlink" }},
"filesystem": {{
"read_file": ["{target}"]
}},
"policy": {{
"add_deny_access": ["{link}"],
"override_deny": ["{link}"]
}}
}}"#,
target = target.display(),
link = link.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.fs_capabilities()
.iter()
.any(|cap| cap.is_file && cap.resolved == target_canonical),
"override via symlink path should preserve the file grant for the canonical target"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_profile_workdir_deny_env_extends_claude_code() {
let workdir = tempdir().expect("workdir");
std::fs::write(workdir.path().join(".env"), "SECRET=test").expect("write .env");
let profile_path = workdir.path().join("deny-env.json");
std::fs::write(
&profile_path,
r#"{
"extends": "claude-code",
"meta": { "name": "claude-code-deny-env" },
"policy": {
"add_deny_access": ["$WORKDIR/.env"]
}
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let rules = caps.platform_rules().join("\n");
let env_path = workdir.path().join(".env");
let env_canonical = env_path.canonicalize().expect("canonicalize .env");
let has_canonical_deny = rules.contains(&format!(
"deny file-read-data (literal \"{}\")",
env_canonical.display()
));
let has_original_deny = rules.contains(&format!(
"deny file-read-data (literal \"{}\")",
env_path.display()
));
assert!(
has_canonical_deny,
"deny rule must use canonical path {}.\n\
Has original path deny: {}\n\
Original path: {}\n\
Canonical path: {}\n\
All platform rules:\n{}",
env_canonical.display(),
has_original_deny,
env_path.display(),
env_canonical.display(),
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_profile_policy_add_deny_access_emits_seatbelt_rules() {
let dir = tempdir().expect("tmpdir");
let denied = dir.path().join("denied");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let profile_path = dir.path().join("policy-deny-macos.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "policy-deny-macos" }},
"policy": {{
"add_deny_access": ["{}"]
}}
}}"#,
denied.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains("deny file-read-data"),
"expected macOS deny read rule, got:\n{}",
rules
);
assert!(
rules.contains("deny file-write*"),
"expected macOS deny write rule, got:\n{}",
rules
);
}
#[test]
fn test_from_profile_policy_override_deny_punches_through_deny_group() {
let dir = tempdir().expect("tmpdir");
let denied = dir.path().join("denied_dir");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let profile_path = dir.path().join("override-deny-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "override-deny-test" }},
"policy": {{
"add_allow_readwrite": ["{path}"],
"add_deny_access": ["{path}"],
"override_deny": ["{path}"]
}}
}}"#,
path = denied.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let canonical = denied.canonicalize().expect("canonicalize");
assert!(
caps.fs_capabilities()
.iter()
.any(|cap| !cap.is_file && cap.resolved == canonical),
"override_deny should preserve the directory grant despite deny group"
);
}
#[test]
fn test_cli_override_deny_requires_matching_grant() {
let dir = tempdir().expect("tmpdir");
let denied = dir.path().join("denied_no_grant");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let profile_path = dir.path().join("override-deny-no-grant.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "override-deny-no-grant" }},
"policy": {{
"add_deny_access": ["{path}"]
}}
}}"#,
path = denied.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
override_deny: vec![denied.clone()],
..sandbox_args()
};
let err = from_profile_locked(&profile, workdir.path(), &args)
.expect_err("CLI override_deny without user-intent grant should fail");
assert!(
err.to_string().contains("no matching grant"),
"unexpected error: {err}"
);
}
#[test]
fn test_profile_override_deny_requires_matching_grant() {
let dir = tempdir().expect("tmpdir");
let denied = dir.path().join("denied_no_grant");
std::fs::create_dir_all(&denied).expect("mkdir denied");
let profile_path = dir.path().join("override-deny-no-grant.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "override-deny-no-grant" }},
"policy": {{
"add_deny_access": ["{path}"],
"override_deny": ["{path}"]
}}
}}"#,
path = denied.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let err = from_profile_locked(&profile, workdir.path(), &args)
.expect_err("profile override_deny without grant should fail");
assert!(
err.to_string().contains("no matching grant"),
"unexpected error: {err}"
);
}
#[test]
fn test_from_profile_with_groups() {
let profile = crate::profile::load_profile("claude-code")
.expect("Failed to load claude-code profile");
let workdir = tempdir().expect("Failed to create temp dir");
let args = sandbox_args();
let (mut caps, needs_unlink_overrides) =
from_profile_locked(&profile, workdir.path(), &args).expect("Failed to build");
if needs_unlink_overrides {
policy::apply_unlink_overrides(&mut caps);
}
assert!(caps.has_fs());
if cfg!(target_os = "macos") {
assert!(!caps.platform_rules().is_empty());
let rules = caps.platform_rules().join("\n");
assert!(rules.contains("deny file-read-data"));
assert!(rules.contains("deny file-write*"));
assert!(rules.contains("deny file-write-unlink"));
assert!(
rules.contains("allow file-write-unlink"),
"Expected unlink overrides for writable paths, got:\n{}",
rules
);
}
assert!(caps.blocked_commands().contains(&"rm".to_string()));
assert!(caps.blocked_commands().contains(&"dd".to_string()));
}
#[test]
fn test_cli_allow_upgrades_profile_read_path() {
let dir = tempdir().expect("tmpdir");
let target = dir.path().join("readonly_dir");
std::fs::create_dir(&target).expect("create target dir");
let profile_path = dir.path().join("test-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "test-upgrade" }},
"filesystem": {{ "read": ["{}"] }}
}}"#,
target.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
allow: vec![target.clone()],
..sandbox_args()
};
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let canonical = target.canonicalize().expect("canonicalize target");
let cap = caps
.fs_capabilities()
.iter()
.find(|c| c.resolved == canonical)
.expect("target path should be in capabilities");
assert_eq!(
cap.access,
AccessMode::ReadWrite,
"CLI --allow should upgrade profile read-only path to ReadWrite, got {:?}",
cap.access,
);
}
#[test]
fn test_cli_write_merges_with_profile_read_path() {
let dir = tempdir().expect("tmpdir");
let target = dir.path().join("readonly_dir");
std::fs::create_dir(&target).expect("create target dir");
let profile_path = dir.path().join("test-profile.json");
std::fs::write(
&profile_path,
format!(
r#"{{
"meta": {{ "name": "test-merge" }},
"filesystem": {{ "read": ["{}"] }}
}}"#,
target.display()
),
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
write: vec![target.clone()],
..sandbox_args()
};
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let canonical = target.canonicalize().expect("canonicalize target");
let cap = caps
.fs_capabilities()
.iter()
.find(|c| c.resolved == canonical)
.expect("target path should be in capabilities");
assert_eq!(
cap.access,
AccessMode::ReadWrite,
"CLI --write + profile read should merge to ReadWrite, got {:?}",
cap.access,
);
}
#[test]
fn test_from_profile_allow_net_overrides_proxy_mode() {
let profile = crate::profile::load_profile("claude-code")
.expect("Failed to load claude-code profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
allow_net: true,
..sandbox_args()
};
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(*caps.network_mode(), nono::NetworkMode::AllowAll);
}
#[test]
fn test_from_profile_allow_net_overrides_blocked_network() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("blocked.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "blocked" },
"filesystem": { "allow": ["/tmp"] },
"network": { "block": true }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
allow_net: true,
..sandbox_args()
};
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(*caps.network_mode(), nono::NetworkMode::AllowAll);
}
#[test]
fn test_from_profile_process_info_mode_same_sandbox() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("pim-test.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "pim-test" },
"filesystem": { "allow": ["/tmp"] },
"security": { "process_info_mode": "allow_same_sandbox" }
}"#,
)
.expect("write profile");
let workdir = tempdir().expect("tmpdir");
let args = sandbox_args();
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(
caps.process_info_mode(),
nono::ProcessInfoMode::AllowSameSandbox,
"profile process_info_mode should propagate to CapabilitySet"
);
}
#[test]
fn test_from_profile_ipc_mode_full() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("ipc-test.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "ipc-test" },
"filesystem": { "allow": ["/tmp"] },
"security": { "ipc_mode": "full" }
}"#,
)
.expect("write profile");
let workdir = tempdir().expect("tmpdir");
let args = sandbox_args();
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(
caps.ipc_mode(),
nono::IpcMode::Full,
"profile ipc_mode should propagate to CapabilitySet"
);
}
#[test]
fn test_from_profile_ipc_mode_shared_memory_only() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("ipc-test-shm.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "ipc-test-shm" },
"filesystem": { "allow": ["/tmp"] },
"security": { "ipc_mode": "shared_memory_only" }
}"#,
)
.expect("write profile");
let workdir = tempdir().expect("tmpdir");
let args = sandbox_args();
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(
caps.ipc_mode(),
nono::IpcMode::SharedMemoryOnly,
"profile ipc_mode: shared_memory_only should propagate to CapabilitySet"
);
}
#[test]
fn test_from_profile_ipc_mode_default() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("ipc-test-default.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "ipc-test-default" },
"filesystem": { "allow": ["/tmp"] },
"security": {}
}"#,
)
.expect("write profile");
let workdir = tempdir().expect("tmpdir");
let args = sandbox_args();
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(
caps.ipc_mode(),
nono::IpcMode::SharedMemoryOnly,
"absent profile ipc_mode should default to SharedMemoryOnly"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_regex_escape_path_dots() {
assert_eq!(
regex_escape_path("/Users/me/.claude.json"),
"/Users/me/\\.claude\\.json"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn test_from_args_allow_connect_port_populates_tcp_connect_ports() {
let args = SandboxArgs {
allow_connect_port: vec![443, 80, 5432],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("build caps");
assert_eq!(caps.tcp_connect_ports(), &[443, 80, 5432]);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_args_allow_connect_port_errors_on_macos() {
let args = SandboxArgs {
allow_connect_port: vec![443],
..sandbox_args()
};
let err = from_args_locked(&args).expect_err("should fail on macOS");
assert!(
err.to_string().contains("not supported on macOS"),
"unexpected error: {err}"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn test_from_profile_connect_port_populates_tcp_connect_ports() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("connect-port-profile.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "connect-port-profile" },
"network": { "connect_port": [443, 5432] }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert_eq!(caps.tcp_connect_ports(), &[443, 5432]);
}
#[cfg(target_os = "macos")]
#[test]
fn test_from_profile_connect_port_errors_on_macos() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("connect-port-profile.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "connect-port-profile" },
"network": { "connect_port": [443] }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let err =
from_profile_locked(&profile, workdir.path(), &args).expect_err("should fail on macOS");
assert!(
err.to_string().contains("not supported on macOS"),
"unexpected error: {err}"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn test_cli_allow_connect_port_overrides_profile_connect_port() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("connect-port-override.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "connect-port-override" },
"network": { "connect_port": [443] }
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = SandboxArgs {
allow_connect_port: vec![5432],
..sandbox_args()
};
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
let ports = caps.tcp_connect_ports();
assert!(ports.contains(&443), "profile port 443 should be present");
assert!(ports.contains(&5432), "CLI port 5432 should be present");
}
#[test]
fn test_from_profile_allow_domain_does_not_open_raw_tcp_ports() {
let dir = tempdir().expect("tmpdir");
let profile_path = dir.path().join("allow-domain-no-raw-ports.json");
std::fs::write(
&profile_path,
r#"{
"meta": { "name": "allow-domain-no-raw-ports" },
"filesystem": { "allow": ["/tmp"] },
"network": {
"allow_domain": [
"api.example.com",
"nats.example.com:4222",
"postgres.example.com:5432"
]
}
}"#,
)
.expect("write profile");
let profile = crate::profile::load_profile_from_path(&profile_path).expect("load profile");
let workdir = tempdir().expect("workdir");
let args = sandbox_args();
let (caps, _) = from_profile_locked(&profile, workdir.path(), &args).expect("build caps");
assert!(
caps.tcp_connect_ports().is_empty(),
"allow_domain should not grant direct TCP ports in proxy mode, got: {:?}",
caps.tcp_connect_ports()
);
}
#[test]
fn test_from_args_allow_proxy_does_not_open_raw_tcp_ports() {
let args = SandboxArgs {
allow_proxy: vec![
"api.example.com".to_string(),
"nats.example.com:4222".to_string(),
],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("build caps");
assert!(
caps.tcp_connect_ports().is_empty(),
"allow-domain should not grant direct TCP ports in proxy mode, got: {:?}",
caps.tcp_connect_ports()
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_regex_escape_path_no_metacharacters() {
assert_eq!(regex_escape_path("/usr/local/bin"), "/usr/local/bin");
}
#[cfg(target_os = "macos")]
#[test]
fn test_regex_escape_path_special_chars() {
assert_eq!(
regex_escape_path("/path/with+parens(1)[2]"),
"/path/with\\+parens\\(1\\)\\[2\\]"
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_atomic_write_rule_adds_regex_for_writable_file() {
let tmp = tempdir().expect("tempdir");
let file_path = tmp.path().join("test.json");
std::fs::write(&file_path, "{}").expect("write");
let cap = FsCapability::new_file(&file_path, AccessMode::ReadWrite).expect("cap");
let mut caps = CapabilitySet::new();
add_atomic_write_rule(&mut caps, &cap).expect("add rule");
let rules = caps.platform_rules().join("\n");
assert!(
rules.contains("file-write*"),
"should contain file-write rule"
);
assert!(
rules.contains(r"\.tmp\.[0-9]+\.[0-9]+"),
"should contain temp file pattern, got: {}",
rules
);
}
#[cfg(target_os = "macos")]
#[test]
fn test_atomic_write_rule_skips_readonly_file() {
let tmp = tempdir().expect("tempdir");
let file_path = tmp.path().join("readonly.json");
std::fs::write(&file_path, "{}").expect("write");
let cap = FsCapability::new_file(&file_path, AccessMode::Read).expect("cap");
let mut caps = CapabilitySet::new();
add_atomic_write_rule(&mut caps, &cap).expect("add rule");
assert!(
caps.platform_rules().is_empty(),
"read-only file should not get atomic write rule"
);
}
#[test]
fn test_allow_unix_socket_adds_cap_and_implied_read_fs_grant() {
let dir = tempdir().expect("tempdir");
let sock = dir.path().join("a.sock");
std::fs::write(&sock, b"").expect("create socket stub");
let args = SandboxArgs {
allow_unix_socket: vec![sock.clone()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::Connect);
assert!(!socks[0].is_directory);
let fs_matches: Vec<_> = caps
.fs_capabilities()
.iter()
.filter(|c| c.is_file && c.resolved == sock.canonicalize().expect("canonicalize sock"))
.collect();
assert_eq!(fs_matches.len(), 1);
assert_eq!(fs_matches[0].access, AccessMode::Read);
}
#[test]
#[cfg(unix)]
fn test_allow_unix_socket_bind_rejects_dangling_symlink() {
let dir = tempdir().expect("tempdir");
let link = dir.path().join("dangling.sock");
let missing_target = dir.path().join("does-not-exist");
std::os::unix::fs::symlink(&missing_target, &link).expect("create dangling symlink");
let args = SandboxArgs {
allow_unix_socket_bind: vec![link],
..sandbox_args()
};
let err = from_args_locked(&args)
.expect_err("dangling symlink must be rejected by the bind guard");
assert!(
format!("{err}").contains("dangling symlink"),
"error message should mention dangling symlink"
);
}
#[test]
fn test_allow_unix_socket_missing_skips_both_socket_and_fs_grants() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("never-exists.sock");
let args = SandboxArgs {
allow_unix_socket: vec![missing.clone()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
assert!(
caps.unix_socket_capabilities().is_empty(),
"no unix-socket grant for missing path"
);
assert!(
!caps
.fs_capabilities()
.iter()
.any(|c| c.original == missing || c.resolved == missing),
"no implied fs grant when the socket grant was skipped"
);
}
#[test]
fn test_allow_unix_socket_bind_accepts_nonexistent_path_and_widens_fs_to_parent() {
let dir = tempdir().expect("tempdir");
let pending = dir.path().join("future.sock");
assert!(!pending.exists(), "test precondition: path must not exist");
let args = SandboxArgs {
allow_unix_socket_bind: vec![pending.clone()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
assert!(!socks[0].is_directory);
let canonical_parent = dir.path().canonicalize().expect("canonicalize dir");
let parent_grant = caps
.fs_capabilities()
.iter()
.find(|c| !c.is_file && c.resolved == canonical_parent)
.expect("implied parent-dir fs grant missing");
assert_eq!(parent_grant.access, AccessMode::ReadWrite);
}
#[test]
fn test_allow_unix_socket_bind_existing_grants_readwrite_fs() {
let dir = tempdir().expect("tempdir");
let sock = dir.path().join("b.sock");
std::fs::write(&sock, b"").expect("create socket stub");
let args = SandboxArgs {
allow_unix_socket_bind: vec![sock.clone()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
let fs_match = caps
.fs_capabilities()
.iter()
.find(|c| c.is_file && c.resolved == sock.canonicalize().expect("canonicalize sock"))
.expect("implied fs cap not found");
assert_eq!(fs_match.access, AccessMode::ReadWrite);
}
#[test]
fn test_allow_unix_socket_dir_bind_directory_grants_readwrite_fs() {
let dir = tempdir().expect("tempdir");
let args = SandboxArgs {
allow_unix_socket_dir_bind: vec![dir.path().to_path_buf()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
assert!(socks[0].is_directory);
let fs_match = caps
.fs_capabilities()
.iter()
.find(|c| {
!c.is_file && c.resolved == dir.path().canonicalize().expect("canonicalize dir")
})
.expect("implied fs dir cap not found");
assert_eq!(fs_match.access, AccessMode::ReadWrite);
}
#[test]
fn test_allow_unix_socket_dir_implies_read_fs_grant() {
let dir = tempdir().expect("tempdir");
let args = SandboxArgs {
allow_unix_socket_dir: vec![dir.path().to_path_buf()],
..sandbox_args()
};
let (caps, _) = from_args_locked(&args).expect("from_args");
let socks = caps.unix_socket_capabilities();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::Connect);
assert!(socks[0].is_directory);
let fs_match = caps
.fs_capabilities()
.iter()
.find(|c| {
!c.is_file && c.resolved == dir.path().canonicalize().expect("canonicalize dir")
})
.expect("implied fs dir cap not found");
assert_eq!(fs_match.access, AccessMode::Read);
}
fn profile_with_fs_field(field: &str, value: &str) -> crate::profile::Profile {
let json = format!(
r#"{{
"meta": {{ "name": "test-unix-socket" }},
"security": {{ "groups": [] }},
"filesystem": {{ "{field}": ["{value}"] }}
}}"#
);
serde_json::from_str(&json).expect("parse profile")
}
#[test]
fn test_profile_unix_socket_field_connect_file() {
let dir = tempdir().expect("tempdir");
let sock = dir.path().join("a.sock");
std::fs::write(&sock, b"").expect("create socket stub");
let profile = profile_with_fs_field("unix_socket", &sock.display().to_string());
let (caps, _) =
from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
let socks: Vec<_> = caps
.unix_socket_capabilities()
.iter()
.filter(|c| c.source == CapabilitySource::Profile)
.collect();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::Connect);
assert!(!socks[0].is_directory);
}
#[test]
fn test_profile_unix_socket_field_connect_bind_file() {
let dir = tempdir().expect("tempdir");
let sock = dir.path().join("b.sock");
std::fs::write(&sock, b"").expect("create socket stub");
let profile = profile_with_fs_field("unix_socket_bind", &sock.display().to_string());
let (caps, _) =
from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
let socks: Vec<_> = caps
.unix_socket_capabilities()
.iter()
.filter(|c| c.source == CapabilitySource::Profile)
.collect();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
assert!(!socks[0].is_directory);
}
#[test]
fn test_profile_unix_socket_field_connect_dir() {
let dir = tempdir().expect("tempdir");
let profile = profile_with_fs_field("unix_socket_dir", &dir.path().display().to_string());
let (caps, _) =
from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
let socks: Vec<_> = caps
.unix_socket_capabilities()
.iter()
.filter(|c| c.source == CapabilitySource::Profile)
.collect();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::Connect);
assert!(socks[0].is_directory);
}
#[test]
fn test_profile_unix_socket_field_connect_bind_dir() {
let dir = tempdir().expect("tempdir");
let profile =
profile_with_fs_field("unix_socket_dir_bind", &dir.path().display().to_string());
let (caps, _) =
from_profile_locked(&profile, dir.path(), &sandbox_args()).expect("from_profile");
let socks: Vec<_> = caps
.unix_socket_capabilities()
.iter()
.filter(|c| c.source == CapabilitySource::Profile)
.collect();
assert_eq!(socks.len(), 1);
assert_eq!(socks[0].mode, UnixSocketMode::ConnectBind);
assert!(socks[0].is_directory);
}
}