use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::orchestration::CapabilityPolicy;
use crate::value::VmError;
use super::{
effective_fallback, normalize_for_policy, path_is_within, sandbox_denial_error,
sandbox_signal_status, sandbox_user_home_dir, ActiveBackend, 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))
}