use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::orchestration::{CapabilityPolicy, SandboxProfile};
use crate::value::{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,
};
#[path = "refusal_mechanism.rs"]
mod refusal_mechanism;
pub use refusal_mechanism::{
infer_process_sandbox_mechanism, ProcessSandboxGrants, ProcessSandboxMechanism,
};
#[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,
#[serde(default)]
pub mechanism: ProcessSandboxMechanism,
#[serde(default)]
pub reason: String,
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(
"mechanism".to_string(),
serde_json::json!(self.mechanism.as_str()),
);
metadata.insert("reason".to_string(), serde_json::json!(self.reason));
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 {
Self::inferred_under(backend, command, cwd, evidence, None)
}
pub fn inferred_under(
backend: String,
command: Vec<String>,
cwd: String,
evidence: &str,
policy: Option<&CapabilityPolicy>,
) -> Self {
let mut stderr_excerpt: String = evidence.chars().take(Self::MAX_EXCERPT).collect();
if evidence.chars().count() > Self::MAX_EXCERPT {
stderr_excerpt.push('…');
}
let (mechanism, grants) = infer_process_sandbox_mechanism(evidence, policy);
Self {
schema: Self::SCHEMA.to_string(),
command,
cwd,
backend,
operation: ProcessSandboxOperation::Unknown,
mechanism,
reason: mechanism.explanation(&grants),
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(),
"mechanism": self.mechanism.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": format!("The process sandbox refused an operation in the child process: {}.", self.reason),
})
}
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,
pub policy: Option<Box<CapabilityPolicy>>,
}
impl ProcessSandboxReportingContext {
pub fn current() -> Self {
let policy = super::active_sandbox_policy();
let reporting = match policy {
Some(_) if ActiveBackend::available() => ProcessSandboxDenialReporting::InferredOnly,
Some(_) => ProcessSandboxDenialReporting::BackendUnavailable,
None if super::self_confinement::current_process_confinement().is_some() => {
ProcessSandboxDenialReporting::InferredOnly
}
None => ProcessSandboxDenialReporting::NotEnforced,
};
Self {
backend: super::active_backend_filesystem_mechanism().to_string(),
reporting,
policy: policy.map(|(policy, _)| Box::new(policy)),
}
}
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_under(
self.backend.clone(),
command.to_vec(),
cwd.to_string(),
&String::from_utf8_lossy(evidence),
self.policy.as_deref(),
)
});
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 {}); \
mechanism={}: {}",
output.status,
refusal.mechanism.as_str(),
refusal.reason,
),
&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))
}
pub use crate::value::{
SandboxMechanism, SandboxMechanismAvailability, SandboxMechanismUnavailable, SandboxRequirement,
};
use crate::value::NO_OS_SANDBOX;
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 {
if mechanism == SandboxMechanism::Unconfined {
return format!("{NO_OS_SANDBOX}; child processes run unconfined");
}
let fact = availability.description();
format!(
"{} {fact}; process filesystem isolation is disabled",
mechanism.display_name()
)
}
#[cfg(test)]
#[path = "refusal_tests.rs"]
mod refusal_tests;