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,
}
impl RefusalObservability {
pub fn as_str(self) -> &'static str {
match self {
Self::Inferred => "inferred",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProcessSandboxDenialReporting {
NotEnforced,
BackendUnavailable,
InferredOnly,
}
impl ProcessSandboxDenialReporting {
pub fn as_str(self) -> &'static str {
match self {
Self::NotEnforced => "not_enforced",
Self::BackendUnavailable => "backend_unavailable",
Self::InferredOnly => "inferred_only",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProcessSandboxOperation {
Read,
Write,
Unknown,
}
impl ProcessSandboxOperation {
pub fn as_str(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Unknown => "unknown",
}
}
}
#[cfg(unix)]
pub fn is_process_sandbox_signal(signal: Option<i32>) -> bool {
matches!(
signal,
Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
)
}
#[cfg(not(unix))]
pub fn is_process_sandbox_signal(_signal: Option<i32>) -> bool {
false
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProcessSandboxRefusal {
pub schema: String,
pub command: Vec<String>,
pub cwd: String,
pub backend: String,
pub operation: ProcessSandboxOperation,
pub resource: Option<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("backend".to_string(), serde_json::json!(self.backend));
metadata.insert(
"operation".to_string(),
serde_json::json!(self.operation.as_str()),
);
metadata.insert("resource".to_string(), serde_json::json!(self.resource));
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(
"stderr_excerpt".to_string(),
serde_json::json!(self.stderr_excerpt),
);
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(backend: String, 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,
backend,
operation: ProcessSandboxOperation::Unknown,
resource: None,
refused_paths: Vec::new(),
observability: RefusalObservability::Inferred,
stderr_excerpt,
count: 1,
}
}
pub fn handler_denial_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"gate": "process_sandbox",
"capability": "process.run",
"backend": self.backend,
"operation": self.operation.as_str(),
"resource": self.resource,
"command": self.command,
"cwd": self.cwd,
"refused_paths": self.refused_paths,
"observability": self.observability.as_str(),
"stderr_excerpt": self.stderr_excerpt,
"count": self.count,
"retryable": false,
"reason": "The process sandbox refused an operation in the child process.",
})
}
pub fn handler_denial_value(&self) -> VmValue {
crate::json_to_vm_value(&self.handler_denial_json())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProcessSandboxReportingContext {
pub backend: String,
pub reporting: ProcessSandboxDenialReporting,
}
impl ProcessSandboxReportingContext {
pub fn current() -> Self {
let reporting = match super::active_sandbox_policy() {
Some(_) if ActiveBackend::available() => ProcessSandboxDenialReporting::InferredOnly,
Some(_) => ProcessSandboxDenialReporting::BackendUnavailable,
_ => ProcessSandboxDenialReporting::NotEnforced,
};
Self {
backend: super::active_backend_filesystem_mechanism().to_string(),
reporting,
}
}
pub fn assess_exit(
&self,
success: bool,
sandbox_signal: bool,
stdout: &[u8],
stderr: &[u8],
command: &[String],
cwd: &str,
) -> ProcessSandboxAssessment {
let stderr_lower = String::from_utf8_lossy(stderr).to_ascii_lowercase();
let stdout_lower = String::from_utf8_lossy(stdout).to_ascii_lowercase();
let stderr_permission = stderr_lower.contains("operation not permitted")
|| stderr_lower.contains("permission denied")
|| stderr_lower.contains("access is denied");
let stdout_permission = stdout_lower.contains("operation not permitted");
let permission_errno = !success && (stderr_permission || stdout_permission);
let refusal = (self.reporting == ProcessSandboxDenialReporting::InferredOnly
&& (permission_errno || sandbox_signal))
.then(|| {
let evidence = if stderr_permission || (!stdout_permission && !stderr.is_empty()) {
stderr
} else {
stdout
};
ProcessSandboxRefusal::inferred(
self.backend.clone(),
command.to_vec(),
cwd.to_string(),
&String::from_utf8_lossy(evidence),
)
});
ProcessSandboxAssessment {
reporting: self.reporting,
refusal,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProcessSandboxAssessment {
pub reporting: ProcessSandboxDenialReporting,
pub refusal: Option<ProcessSandboxRefusal>,
}
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;
}
let sandbox_signal = sandbox_signal_status(output);
let assessment = ProcessSandboxReportingContext::current().assess_exit(
output.status.success(),
sandbox_signal,
&output.stdout,
&output.stderr,
command,
cwd,
);
if let Some(refusal) = assessment.refusal {
refusal.emit();
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let action = if sandbox_signal {
"terminated"
} else {
"denied"
};
return Some(sandbox_denial_error(
format!(
"sandbox violation: process was {action} 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;