use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::orchestration::{CapabilityPolicy, SandboxProfile};
use crate::value::{ErrorCategory, VmDictExt, VmError, VmValue};
use super::{
effective_fallback, normalize_for_policy, path_is_within, sandbox_denial_error,
sandbox_signal_status, sandbox_user_home_dir, warn_once, ActiveBackend, PrepareOutcome,
SandboxBackend, SandboxFallback,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RefusalObservability {
Inferred,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessSandboxRefusal {
pub schema: String,
pub command: Vec<String>,
pub cwd: String,
pub refused_paths: Vec<String>,
pub observability: RefusalObservability,
pub stderr_excerpt: String,
pub count: u32,
}
impl ProcessSandboxRefusal {
pub const SCHEMA: &'static str = "harn.process.sandbox_refusal.v1";
const MAX_EXCERPT: usize = 512;
pub fn emit(&self) {
let mut metadata = std::collections::BTreeMap::new();
metadata.insert("schema".to_string(), serde_json::json!(self.schema));
metadata.insert("command".to_string(), serde_json::json!(self.command));
metadata.insert("cwd".to_string(), serde_json::json!(self.cwd));
metadata.insert(
"refused_paths".to_string(),
serde_json::json!(self.refused_paths),
);
metadata.insert(
"observability".to_string(),
serde_json::to_value(self.observability).unwrap_or(serde_json::Value::Null),
);
metadata.insert("count".to_string(), serde_json::json!(self.count));
crate::events::log_warn_meta(
"process_sandbox_refusal",
"a child process was refused by the OS sandbox",
metadata,
);
}
pub fn inferred(command: Vec<String>, cwd: String, evidence: &str) -> Self {
let mut stderr_excerpt: String = evidence.chars().take(Self::MAX_EXCERPT).collect();
if evidence.chars().count() > Self::MAX_EXCERPT {
stderr_excerpt.push('…');
}
Self {
schema: Self::SCHEMA.to_string(),
command,
cwd,
refused_paths: Vec::new(),
observability: RefusalObservability::Inferred,
stderr_excerpt,
count: 1,
}
}
}
pub fn process_violation_error(
output: &std::process::Output,
command: &[String],
cwd: &str,
) -> Option<VmError> {
let policy = crate::orchestration::current_execution_policy()?;
if !policy.sandbox_profile.confines_processes() {
return None;
}
if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
|| !ActiveBackend::available()
{
return None;
}
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
if !output.status.success()
&& (stderr.contains("operation not permitted")
|| stderr.contains("permission denied")
|| stderr.contains("access is denied")
|| stdout.contains("operation not permitted"))
{
ProcessSandboxRefusal::inferred(command.to_vec(), cwd.to_string(), &stderr).emit();
return Some(sandbox_denial_error(
format!(
"sandbox violation: process was denied by the OS sandbox (status {})",
output.status.code().unwrap_or(-1)
),
&format!("{stderr}\n{stdout}"),
&policy,
));
}
if sandbox_signal_status(output) {
return Some(sandbox_denial_error(
format!(
"sandbox violation: process was terminated by the OS sandbox (status {})",
output.status
),
&format!("{stderr}\n{stdout}"),
&policy,
));
}
None
}
pub(crate) fn process_sandbox_read_deny_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
let mut denied: Vec<PathBuf> = Vec::new();
if let Some(home) = sandbox_user_home_dir() {
for relative in crate::orchestration::default_read_deny_home_paths() {
denied.push(normalize_for_policy(&home.join(relative)));
}
}
for root in &policy.process_sandbox.read_deny_roots {
let normalized = normalize_for_policy(Path::new(root));
if !denied.contains(&normalized) {
denied.push(normalized);
}
}
denied
}
pub(crate) fn path_is_denied(candidate: &Path, denied: &[PathBuf]) -> bool {
denied.iter().any(|root| path_is_within(candidate, root))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SandboxMechanism {
LinuxLandlock,
MacosSandboxExec,
WindowsAppContainer,
}
impl SandboxMechanism {
pub fn as_str(self) -> &'static str {
match self {
Self::LinuxLandlock => "linux_landlock",
Self::MacosSandboxExec => "macos_sandbox_exec",
Self::WindowsAppContainer => "windows_app_container",
}
}
pub fn display_name(self) -> &'static str {
match self {
Self::LinuxLandlock => "Linux Landlock",
Self::MacosSandboxExec => "macOS sandbox-exec",
Self::WindowsAppContainer => "Windows AppContainer",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SandboxMechanismAvailability {
AbsentOnHost,
EntryPointCannotAttach,
}
impl SandboxMechanismAvailability {
pub fn as_str(self) -> &'static str {
match self {
Self::AbsentOnHost => "absent_on_host",
Self::EntryPointCannotAttach => "entry_point_cannot_attach",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SandboxRequirement {
Profile,
Fallback,
}
impl SandboxRequirement {
pub fn as_str(self) -> &'static str {
match self {
Self::Profile => "profile",
Self::Fallback => "fallback",
}
}
pub fn selector_is_honored(self) -> bool {
matches!(self, Self::Fallback)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SandboxMechanismUnavailable {
pub schema: String,
pub mechanism: SandboxMechanism,
pub availability: SandboxMechanismAvailability,
pub profile: SandboxProfile,
pub requirement: SandboxRequirement,
}
impl SandboxMechanismUnavailable {
pub const SCHEMA: &'static str = "harn.process.sandbox_mechanism_unavailable.v1";
pub(crate) fn new(
mechanism: SandboxMechanism,
availability: SandboxMechanismAvailability,
profile: SandboxProfile,
) -> Self {
let requirement = if matches!(profile, SandboxProfile::OsHardened) {
SandboxRequirement::Profile
} else {
SandboxRequirement::Fallback
};
Self {
schema: Self::SCHEMA.to_string(),
mechanism,
availability,
profile,
requirement,
}
}
pub fn category(&self) -> ErrorCategory {
ErrorCategory::ToolRejected
}
pub(crate) fn into_error(self) -> VmError {
VmError::SandboxMechanismUnavailable(Box::new(self))
}
pub fn thrown_value(&self) -> VmValue {
let mut cause = std::collections::BTreeMap::new();
cause.put_str("schema", self.schema.as_str());
cause.put_str("mechanism", self.mechanism.as_str());
cause.put_str("availability", self.availability.as_str());
cause.put_str("profile", self.profile.as_str());
cause.put_str("requirement", self.requirement.as_str());
cause.insert(
"selector_honored".to_string(),
VmValue::Bool(self.requirement.selector_is_honored()),
);
let mut dict = std::collections::BTreeMap::new();
dict.put_str("category", self.category().as_str());
dict.put_str("message", self.to_string());
dict.put_str("source", "sandbox_mechanism");
dict.insert("sandbox_mechanism".to_string(), VmValue::dict(cause));
VmValue::dict(dict)
}
}
impl std::fmt::Display for SandboxMechanismUnavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let fact = match self.availability {
SandboxMechanismAvailability::AbsentOnHost => {
format!(
"{} is not available on this host",
self.mechanism.display_name()
)
}
SandboxMechanismAvailability::EntryPointCannotAttach => format!(
"{} cannot be attached through this spawn entry point",
self.mechanism.display_name()
),
};
let requirement = match self.requirement {
SandboxRequirement::Profile => "the requested sandbox profile requires it",
SandboxRequirement::Fallback => "the resolved sandbox fallback requires it",
};
write!(f, "{fact}; {requirement}")
}
}
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
pub(crate) fn unavailable(
mechanism: SandboxMechanism,
availability: SandboxMechanismAvailability,
profile: SandboxProfile,
) -> Result<PrepareOutcome, VmError> {
match effective_fallback(profile) {
SandboxFallback::Off | SandboxFallback::Warn => {
warn_once(
"handler_sandbox_unavailable",
&mechanism_skipped_warning(mechanism, availability),
);
Ok(PrepareOutcome::Direct)
}
SandboxFallback::Enforce => {
Err(SandboxMechanismUnavailable::new(mechanism, availability, profile).into_error())
}
}
}
pub(crate) fn mechanism_skipped_warning(
mechanism: SandboxMechanism,
availability: SandboxMechanismAvailability,
) -> String {
let fact = match availability {
SandboxMechanismAvailability::AbsentOnHost => "is not available on this host",
SandboxMechanismAvailability::EntryPointCannotAttach => {
"cannot be attached through this spawn entry point"
}
};
format!(
"{} {fact}; process filesystem isolation is disabled",
mechanism.display_name()
)
}
#[cfg(test)]
#[path = "refusal_tests.rs"]
mod refusal_tests;