Files
owlen/crates/owlen-core/src/sandbox.rs
vikingowl 498e6e61b6 feat(tui): add markdown rendering support and toggle command
- Introduce new `owlen-markdown` crate that converts Markdown strings to `ratatui::Text` with headings, lists, bold/italic, and inline code.
- Add `render_markdown` config option (default true) and expose it via `app.render_markdown_enabled()`.
- Implement `:markdown [on|off]` command to toggle markdown rendering.
- Update help overlay to document the new markdown toggle.
- Adjust UI rendering to conditionally apply markdown styling based on the markdown flag and code mode.
- Wire the new crate into `owlen-tui` Cargo.toml.
2025-10-14 01:35:13 +02:00

217 lines
7.4 KiB
Rust

use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use tempfile::TempDir;
/// Configuration options for sandboxed process execution.
#[derive(Clone, Debug)]
pub struct SandboxConfig {
pub allow_network: bool,
pub allow_paths: Vec<PathBuf>,
pub readonly_paths: Vec<PathBuf>,
pub timeout_seconds: u64,
pub max_memory_mb: u64,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
allow_network: false,
allow_paths: Vec::new(),
readonly_paths: Vec::new(),
timeout_seconds: 30,
max_memory_mb: 512,
}
}
}
/// Wrapper around a bubblewrap sandbox instance.
///
/// Memory limits are enforced via:
/// - bwrap's --rlimit-as (version >= 0.12.0)
/// - prlimit wrapper (fallback for older bwrap versions)
/// - timeout mechanism (always enforced as last resort)
pub struct SandboxedProcess {
temp_dir: TempDir,
config: SandboxConfig,
}
impl SandboxedProcess {
pub fn new(config: SandboxConfig) -> Result<Self> {
let temp_dir = TempDir::new().context("Failed to create temp directory")?;
which::which("bwrap")
.context("bubblewrap not found. Install with: sudo apt install bubblewrap")?;
Ok(Self { temp_dir, config })
}
pub fn execute(&self, command: &str, args: &[&str]) -> Result<SandboxResult> {
let supports_rlimit = self.supports_rlimit_as();
let use_prlimit = !supports_rlimit && which::which("prlimit").is_ok();
let mut cmd = if use_prlimit {
// Use prlimit wrapper for older bwrap versions
let mut prlimit_cmd = Command::new("prlimit");
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
prlimit_cmd.arg(format!("--as={}", memory_limit_bytes));
prlimit_cmd.arg("bwrap");
prlimit_cmd
} else {
Command::new("bwrap")
};
cmd.args(["--unshare-all", "--die-with-parent", "--new-session"]);
if self.config.allow_network {
cmd.arg("--share-net");
} else {
cmd.arg("--unshare-net");
}
cmd.args(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"]);
// Bind essential system paths readonly for executables and libraries
let system_paths = ["/usr", "/bin", "/lib", "/lib64", "/etc"];
for sys_path in &system_paths {
let path = std::path::Path::new(sys_path);
if path.exists() {
cmd.arg("--ro-bind").arg(sys_path).arg(sys_path);
}
}
// Bind /run for DNS resolution (resolv.conf may be a symlink to /run/systemd/resolve/*)
if std::path::Path::new("/run").exists() {
cmd.arg("--ro-bind").arg("/run").arg("/run");
}
for path in &self.config.allow_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--bind").arg(&path_host).arg(&path_guest);
}
for path in &self.config.readonly_paths {
let path_host = path.to_string_lossy().into_owned();
let path_guest = path_host.clone();
cmd.arg("--ro-bind").arg(&path_host).arg(&path_guest);
}
let work_dir = self.temp_dir.path().to_string_lossy().into_owned();
cmd.arg("--bind").arg(&work_dir).arg("/work");
cmd.arg("--chdir").arg("/work");
// Add memory limits via bwrap's --rlimit-as if supported (version >= 0.12.0)
// If not supported, we use prlimit wrapper (set earlier)
if supports_rlimit && !use_prlimit {
let memory_limit_bytes = self
.config
.max_memory_mb
.saturating_mul(1024)
.saturating_mul(1024);
let memory_soft = memory_limit_bytes.to_string();
let memory_hard = memory_limit_bytes.to_string();
cmd.arg("--rlimit-as").arg(&memory_soft).arg(&memory_hard);
}
cmd.arg(command);
cmd.args(args);
let start = Instant::now();
let timeout = Duration::from_secs(self.config.timeout_seconds);
// Spawn the process instead of waiting immediately
let mut child = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn sandboxed command")?;
let mut was_timeout = false;
// Wait for the child with timeout
let output = loop {
match child.try_wait() {
Ok(Some(_status)) => {
// Process exited
let output = child
.wait_with_output()
.context("Failed to collect process output")?;
break output;
}
Ok(None) => {
// Process still running, check timeout
if start.elapsed() >= timeout {
// Timeout exceeded, kill the process
was_timeout = true;
child.kill().context("Failed to kill timed-out process")?;
// Wait for the killed process to exit
let output = child
.wait_with_output()
.context("Failed to collect output from killed process")?;
break output;
}
// Sleep briefly before checking again
std::thread::sleep(Duration::from_millis(50));
}
Err(e) => {
bail!("Failed to check process status: {}", e);
}
}
};
let duration = start.elapsed();
Ok(SandboxResult {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
exit_code: output.status.code().unwrap_or(-1),
duration,
was_timeout,
})
}
/// Check if bubblewrap supports --rlimit-as option (version >= 0.12.0)
fn supports_rlimit_as(&self) -> bool {
// Try to get bwrap version
let output = Command::new("bwrap").arg("--version").output();
if let Ok(output) = output {
let version_str = String::from_utf8_lossy(&output.stdout);
// Parse version like "bubblewrap 0.11.0" or "0.11.0"
return version_str
.split_whitespace()
.last()
.and_then(|part| {
part.split_once('.').and_then(|(major, rest)| {
rest.split_once('.').and_then(|(minor, _)| {
let maj = major.parse::<u32>().ok()?;
let min = minor.parse::<u32>().ok()?;
Some((maj, min))
})
})
})
.map(|(maj, min)| maj > 0 || (maj == 0 && min >= 12))
.unwrap_or(false);
}
// If we can't determine the version, assume it doesn't support it (safer default)
false
}
}
#[derive(Debug, Clone)]
pub struct SandboxResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub duration: Duration,
pub was_timeout: bool,
}