use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use openpaw_core::{Error, Result};
pub struct EnvironmentResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
pub fn exit_code_from(status: &std::process::ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(signal) = status.signal() {
return -signal;
}
}
-1
}
#[async_trait]
pub trait Environment: Send + Sync {
async fn exec(&self, command: &str, cwd: Option<&Path>) -> EnvironmentResult;
async fn push_file(&self, local: &Path, remote: &Path) -> Result<()>;
async fn pull_file(&self, remote: &Path, local: &Path) -> Result<()>;
async fn health_check(&self) -> bool;
fn name(&self) -> &str;
}
pub struct LocalEnvironment;
#[async_trait]
impl Environment for LocalEnvironment {
async fn exec(&self, command: &str, cwd: Option<&Path>) -> EnvironmentResult {
let mut cmd = tokio::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" });
cmd.arg(if cfg!(windows) { "/C" } else { "-c" })
.arg(command)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
match cmd.output().await {
Ok(output) => EnvironmentResult {
stdout: decode_output(&output.stdout),
stderr: decode_output(&output.stderr),
exit_code: exit_code_from(&output.status),
},
Err(e) => EnvironmentResult {
stdout: String::new(),
stderr: format!("bash exec error: {e}"),
exit_code: -1,
},
}
}
async fn push_file(&self, local: &Path, remote: &Path) -> Result<()> {
tokio::fs::copy(local, remote)
.await
.map_err(|e| Error::ToolHost(format!("copy file: {e}")))?;
Ok(())
}
async fn pull_file(&self, remote: &Path, local: &Path) -> Result<()> {
tokio::fs::copy(remote, local)
.await
.map_err(|e| Error::ToolHost(format!("copy file: {e}")))?;
Ok(())
}
async fn health_check(&self) -> bool {
true
}
fn name(&self) -> &str {
"local"
}
}
fn decode_output(bytes: &[u8]) -> String {
if bytes.is_empty() {
return String::new();
}
String::from_utf8_lossy(bytes).into_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxPolicy {
#[default]
None,
Restricted,
Isolated,
}
struct FailClosedEnvironment {
reason: String,
}
#[async_trait]
impl Environment for FailClosedEnvironment {
async fn exec(&self, _command: &str, _cwd: Option<&Path>) -> EnvironmentResult {
EnvironmentResult {
stdout: String::new(),
stderr: self.reason.clone(),
exit_code: -1,
}
}
async fn push_file(&self, _local: &Path, _remote: &Path) -> Result<()> {
Err(Error::ToolHost(self.reason.clone()))
}
async fn pull_file(&self, _remote: &Path, _local: &Path) -> Result<()> {
Err(Error::ToolHost(self.reason.clone()))
}
async fn health_check(&self) -> bool {
false
}
fn name(&self) -> &str {
"fail-closed"
}
}
pub struct SandboxedEnvironment {
policy: SandboxPolicy,
workspace: PathBuf,
sandbox_policy: openpaw_sandbox::SandboxPolicy,
}
impl SandboxedEnvironment {
pub fn new(policy: SandboxPolicy, workspace: PathBuf) -> Self {
let sandbox_policy = match &policy {
SandboxPolicy::None => openpaw_sandbox::SandboxPolicy::None,
SandboxPolicy::Restricted => openpaw_sandbox::SandboxPolicy::WorkspaceWrite {
writable_roots: vec![workspace.clone()],
allowed_domains: vec![],
},
SandboxPolicy::Isolated => openpaw_sandbox::SandboxPolicy::ReadOnly,
};
Self {
policy,
workspace,
sandbox_policy,
}
}
pub fn policy(&self) -> &SandboxPolicy {
&self.policy
}
async fn validate_writable_path(&self, path: &Path) -> Result<PathBuf> {
if matches!(self.policy, SandboxPolicy::None) {
return Ok(path.to_path_buf());
}
let canonical = match tokio::fs::canonicalize(path).await {
Ok(c) => c,
Err(_) => {
let parent = path.parent().ok_or_else(|| {
Error::ToolHost("sandboxed copy: path has no parent".to_string())
})?;
let file_name = path.file_name().ok_or_else(|| {
Error::ToolHost("sandboxed copy: path has no file name".to_string())
})?;
let parent_canonical = tokio::fs::canonicalize(parent).await.map_err(|e| {
Error::ToolHost(format!("sandboxed copy: cannot canonicalize parent: {e}"))
})?;
parent_canonical.join(file_name)
}
};
let root_canonical = tokio::fs::canonicalize(&self.workspace)
.await
.map_err(|e| {
Error::ToolHost(format!(
"sandboxed copy: cannot canonicalize workspace: {e}"
))
})?;
if canonical.starts_with(&root_canonical) {
return Ok(canonical);
}
Err(Error::ToolHost(format!(
"sandboxed copy: path {} is outside the allowed writable area",
path.display()
)))
}
async fn check_no_symlink(&self, path: &Path) -> Result<()> {
if matches!(self.policy, SandboxPolicy::None) {
return Ok(());
}
if let Ok(meta) = tokio::fs::symlink_metadata(path).await {
if meta.is_symlink() {
return Err(Error::ToolHost(format!(
"sandboxed copy: refusing to copy via symlink {} (TOCTOU protection)",
path.display()
)));
}
}
Ok(())
}
}
#[async_trait]
impl Environment for SandboxedEnvironment {
async fn exec(&self, command: &str, cwd: Option<&Path>) -> EnvironmentResult {
let effective_cwd = cwd.unwrap_or(&self.workspace);
match self.policy {
SandboxPolicy::None => LocalEnvironment.exec(command, Some(effective_cwd)).await,
SandboxPolicy::Restricted | SandboxPolicy::Isolated => {
match openpaw_sandbox::Sandbox::new(self.sandbox_policy.clone()) {
Ok(sandbox) => match sandbox.exec(command, Some(effective_cwd)).await {
Ok(result) => EnvironmentResult {
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
},
Err(e) => EnvironmentResult {
stdout: String::new(),
stderr: format!("sandbox exec failed: {e}"),
exit_code: -1,
},
},
Err(e) => EnvironmentResult {
stdout: String::new(),
stderr: format!("sandbox init failed: {e}"),
exit_code: -1,
},
}
}
}
}
async fn push_file(&self, local: &Path, remote: &Path) -> Result<()> {
self.validate_writable_path(local).await?;
self.validate_writable_path(remote).await?;
self.check_no_symlink(local).await?;
self.check_no_symlink(remote).await?;
tokio::fs::copy(local, remote)
.await
.map_err(|e| Error::ToolHost(format!("sandboxed copy file: {e}")))?;
self.validate_writable_path(remote).await?;
Ok(())
}
async fn pull_file(&self, remote: &Path, local: &Path) -> Result<()> {
self.validate_writable_path(remote).await?;
self.validate_writable_path(local).await?;
self.check_no_symlink(remote).await?;
self.check_no_symlink(local).await?;
tokio::fs::copy(remote, local)
.await
.map_err(|e| Error::ToolHost(format!("sandboxed copy file: {e}")))?;
self.validate_writable_path(local).await?;
Ok(())
}
async fn health_check(&self) -> bool {
match self.policy {
SandboxPolicy::None => true,
SandboxPolicy::Restricted | SandboxPolicy::Isolated => {
openpaw_sandbox::Sandbox::is_available()
}
}
}
fn name(&self) -> &str {
match self.policy {
SandboxPolicy::None => "local",
SandboxPolicy::Restricted => "sandboxed-restricted",
SandboxPolicy::Isolated => "sandboxed-isolated",
}
}
}
pub enum EnvironmentKind {
Local,
Sandbox(SandboxPolicy),
}
pub fn create_environment(kind: EnvironmentKind, workspace: PathBuf) -> Arc<dyn Environment> {
match kind {
EnvironmentKind::Local => Arc::new(LocalEnvironment),
EnvironmentKind::Sandbox(policy) => {
let sandboxed = SandboxedEnvironment::new(policy.clone(), workspace);
let health_ok = match tokio::runtime::Handle::try_current() {
Ok(handle) => {
tokio::task::block_in_place(|| handle.block_on(sandboxed.health_check()))
}
Err(_) => match tokio::runtime::Runtime::new() {
Ok(rt) => rt.block_on(sandboxed.health_check()),
Err(_) => false,
},
};
if health_ok {
Arc::new(sandboxed)
} else {
match policy {
SandboxPolicy::Isolated => Arc::new(FailClosedEnvironment {
reason: "Isolated sandbox unavailable: health check failed".to_string(),
}),
_ => Arc::new(LocalEnvironment),
}
}
}
}
}
pub fn create_environment_from_policy(
policy: &SandboxPolicy,
workspace: PathBuf,
) -> Arc<dyn Environment> {
match policy {
SandboxPolicy::None => create_environment(EnvironmentKind::Local, workspace),
SandboxPolicy::Restricted | SandboxPolicy::Isolated => {
create_environment(EnvironmentKind::Sandbox(policy.clone()), workspace)
}
}
}