Skip to main content

oris_sandbox/
core.rs

1//! Local process sandbox for applying mutation artifacts into a temporary workspace copy.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::Instant;
7
8use async_trait::async_trait;
9use oris_evolution::{ArtifactEncoding, BlastRadius, MutationId, MutationTarget, PreparedMutation};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13use tokio::time::{timeout, Duration};
14
15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct SandboxPolicy {
17    pub allowed_programs: Vec<String>,
18    pub max_duration_ms: u64,
19    pub max_output_bytes: usize,
20    pub denied_env_prefixes: Vec<String>,
21    /// Maximum address-space (RSS) in bytes for the child process.
22    /// Enforced via `RLIMIT_AS` on Linux when the `resource-limits` feature is enabled.
23    /// Ignored on other platforms.
24    #[serde(default)]
25    pub max_memory_bytes: Option<u64>,
26    /// Maximum CPU time in seconds for the child process.
27    /// Enforced via `RLIMIT_CPU` on Linux when the `resource-limits` feature is enabled.
28    /// Ignored on other platforms.
29    #[serde(default)]
30    pub max_cpu_secs: Option<u64>,
31    /// When `true`, the child is spawned in a new process group and the entire
32    /// group is killed on timeout.  This prevents zombie grandchildren.
33    /// Requires the `resource-limits` feature on Linux; silently ignored otherwise.
34    #[serde(default)]
35    pub use_process_group: bool,
36}
37
38impl SandboxPolicy {
39    pub fn oris_default() -> Self {
40        Self {
41            allowed_programs: vec!["cargo".into(), "rustc".into(), "git".into()],
42            max_duration_ms: 300_000,
43            max_output_bytes: 1_048_576,
44            denied_env_prefixes: vec!["TOKEN".into(), "KEY".into(), "SECRET".into()],
45            max_memory_bytes: None,
46            max_cpu_secs: None,
47            use_process_group: false,
48        }
49    }
50}
51
52impl Default for SandboxPolicy {
53    fn default() -> Self {
54        Self::oris_default()
55    }
56}
57
58#[derive(Clone, Debug)]
59pub struct SandboxReceipt {
60    pub mutation_id: MutationId,
61    pub workdir: PathBuf,
62    pub applied: bool,
63    pub changed_files: Vec<PathBuf>,
64    pub patch_hash: String,
65    pub stdout_log: PathBuf,
66    pub stderr_log: PathBuf,
67}
68
69#[derive(Clone, Debug)]
70pub struct CommandExecution {
71    pub success: bool,
72    pub exit_code: Option<i32>,
73    pub stdout: String,
74    pub stderr: String,
75    pub duration_ms: u64,
76}
77
78#[derive(Debug, Error)]
79pub enum SandboxError {
80    #[error("unsupported artifact encoding")]
81    UnsupportedArtifact,
82    #[error("mutation target violation: {0}")]
83    TargetViolation(String),
84    #[error("sandbox I/O error: {0}")]
85    Io(String),
86    #[error("command denied by policy: {0}")]
87    CommandDenied(String),
88    #[error("command timed out: {0}")]
89    Timeout(String),
90    #[error("patch rejected: {0}")]
91    PatchRejected(String),
92    #[error("patch apply failed: {0}")]
93    PatchApplyFailed(String),
94}
95
96#[async_trait]
97pub trait Sandbox: Send + Sync {
98    async fn apply(
99        &self,
100        mutation: &PreparedMutation,
101        policy: &SandboxPolicy,
102    ) -> Result<SandboxReceipt, SandboxError>;
103}
104
105pub struct LocalProcessSandbox {
106    run_id: String,
107    workspace_root: PathBuf,
108    temp_root: PathBuf,
109}
110
111impl LocalProcessSandbox {
112    pub fn new<P: Into<PathBuf>, Q: Into<PathBuf>, S: Into<String>>(
113        run_id: S,
114        workspace_root: P,
115        temp_root: Q,
116    ) -> Self {
117        Self {
118            run_id: run_id.into(),
119            workspace_root: workspace_root.into(),
120            temp_root: temp_root.into(),
121        }
122    }
123
124    fn sandbox_base(&self, mutation_id: &str) -> PathBuf {
125        self.temp_root.join(&self.run_id).join(mutation_id)
126    }
127}
128
129#[async_trait]
130impl Sandbox for LocalProcessSandbox {
131    async fn apply(
132        &self,
133        mutation: &PreparedMutation,
134        policy: &SandboxPolicy,
135    ) -> Result<SandboxReceipt, SandboxError> {
136        if !matches!(mutation.artifact.encoding, ArtifactEncoding::UnifiedDiff) {
137            return Err(SandboxError::UnsupportedArtifact);
138        }
139
140        let changed_files = parse_changed_files(&mutation.artifact.payload);
141        validate_target(&mutation.intent.target, &changed_files)?;
142
143        let base_dir = self.sandbox_base(&mutation.intent.id);
144        let repo_dir = base_dir.join("repo");
145        let patch_path = base_dir.join("patch.diff");
146        let stdout_log = base_dir.join("apply.stdout.log");
147        let stderr_log = base_dir.join("apply.stderr.log");
148
149        if base_dir.exists() {
150            fs::remove_dir_all(&base_dir).map_err(io_err)?;
151        }
152        fs::create_dir_all(&repo_dir).map_err(io_err)?;
153        copy_workspace(&self.workspace_root, &repo_dir)?;
154        fs::write(&patch_path, mutation.artifact.payload.as_bytes()).map_err(io_err)?;
155
156        let check = execute_allowed_command(
157            policy,
158            &repo_dir,
159            "git",
160            &[
161                String::from("apply"),
162                String::from("--check"),
163                patch_path.to_string_lossy().to_string(),
164            ],
165            policy.max_duration_ms,
166        )
167        .await?;
168        if !check.success {
169            fs::write(&stdout_log, check.stdout.as_bytes()).map_err(io_err)?;
170            fs::write(&stderr_log, check.stderr.as_bytes()).map_err(io_err)?;
171            return Err(SandboxError::PatchRejected(if check.stderr.is_empty() {
172                check.stdout
173            } else {
174                check.stderr
175            }));
176        }
177
178        let apply = execute_allowed_command(
179            policy,
180            &repo_dir,
181            "git",
182            &[
183                String::from("apply"),
184                patch_path.to_string_lossy().to_string(),
185            ],
186            policy.max_duration_ms,
187        )
188        .await?;
189
190        fs::write(&stdout_log, apply.stdout.as_bytes()).map_err(io_err)?;
191        fs::write(&stderr_log, apply.stderr.as_bytes()).map_err(io_err)?;
192        if !apply.success {
193            return Err(SandboxError::PatchApplyFailed(if apply.stderr.is_empty() {
194                apply.stdout
195            } else {
196                apply.stderr
197            }));
198        }
199
200        Ok(SandboxReceipt {
201            mutation_id: mutation.intent.id.clone(),
202            workdir: repo_dir,
203            applied: true,
204            changed_files,
205            patch_hash: hash_patch(&mutation.artifact.payload),
206            stdout_log,
207            stderr_log,
208        })
209    }
210}
211
212pub async fn execute_allowed_command(
213    policy: &SandboxPolicy,
214    workdir: &Path,
215    program: &str,
216    args: &[String],
217    timeout_ms: u64,
218) -> Result<CommandExecution, SandboxError> {
219    if !policy
220        .allowed_programs
221        .iter()
222        .any(|allowed| allowed == program)
223    {
224        return Err(SandboxError::CommandDenied(program.to_string()));
225    }
226
227    let started = Instant::now();
228    let mut command = tokio::process::Command::new(program);
229    command.kill_on_drop(true);
230    command.args(args);
231    command.current_dir(workdir);
232    command.stdout(std::process::Stdio::piped());
233    command.stderr(std::process::Stdio::piped());
234    for (key, _) in std::env::vars() {
235        if policy
236            .denied_env_prefixes
237            .iter()
238            .any(|prefix| key.to_ascii_uppercase().contains(prefix))
239        {
240            command.env_remove(&key);
241        }
242    }
243
244    // Apply OS-level resource limits and process-group isolation.
245    #[cfg(all(feature = "resource-limits", target_os = "linux"))]
246    crate::resource_limits::apply_linux_limits(&mut command, policy);
247
248    // macOS: wrap with sandbox-exec when use_process_group is requested.
249    // On macOS we launch via `sandbox-exec -p <profile> <program> <args...>`.
250    #[cfg(all(feature = "resource-limits", target_os = "macos"))]
251    let _macos_guard = if policy.use_process_group {
252        crate::resource_limits::apply_macos_sandbox(&mut command, program, args)
253    } else {
254        false
255    };
256
257    let output = timeout(Duration::from_millis(timeout_ms), command.output())
258        .await
259        .map_err(|_| SandboxError::Timeout(format!("{program} {}", args.join(" "))))?
260        .map_err(io_err)?;
261
262    Ok(CommandExecution {
263        success: output.status.success(),
264        exit_code: output.status.code(),
265        stdout: truncate_to_limit(&output.stdout, policy.max_output_bytes),
266        stderr: truncate_to_limit(&output.stderr, policy.max_output_bytes),
267        duration_ms: started.elapsed().as_millis() as u64,
268    })
269}
270
271pub fn parse_changed_files(patch: &str) -> Vec<PathBuf> {
272    let mut files = BTreeSet::new();
273    for line in patch.lines() {
274        if let Some(rest) = line.strip_prefix("diff --git ") {
275            let mut parts = rest.split_whitespace();
276            let _lhs = parts.next();
277            if let Some(rhs) = parts.next() {
278                let rhs = rhs.trim_start_matches("b/");
279                if rhs != "/dev/null" {
280                    files.insert(PathBuf::from(rhs));
281                }
282            }
283        } else if let Some(rest) = line.strip_prefix("+++ ") {
284            let path = rest.trim();
285            if path != "/dev/null" {
286                files.insert(PathBuf::from(path.trim_start_matches("b/")));
287            }
288        }
289    }
290    files.into_iter().collect()
291}
292
293pub fn count_changed_lines(patch: &str) -> usize {
294    patch
295        .lines()
296        .filter(|line| {
297            (line.starts_with('+') || line.starts_with('-'))
298                && !line.starts_with("+++")
299                && !line.starts_with("---")
300        })
301        .count()
302}
303
304pub fn compute_blast_radius(patch: &str) -> BlastRadius {
305    BlastRadius {
306        files_changed: parse_changed_files(patch).len(),
307        lines_changed: count_changed_lines(patch),
308    }
309}
310
311pub fn validate_target(
312    target: &MutationTarget,
313    changed_files: &[PathBuf],
314) -> Result<(), SandboxError> {
315    let allowed_prefixes = match target {
316        MutationTarget::WorkspaceRoot => Vec::new(),
317        MutationTarget::Crate { name } => vec![format!("crates/{name}")],
318        MutationTarget::Paths { allow } => allow.clone(),
319    };
320
321    if allowed_prefixes.is_empty() {
322        return Ok(());
323    }
324
325    for file in changed_files {
326        let path = file.to_string_lossy();
327        let allowed = allowed_prefixes
328            .iter()
329            .any(|prefix| path.as_ref() == prefix || path.starts_with(&format!("{prefix}/")));
330        if !allowed {
331            return Err(SandboxError::TargetViolation(path.into_owned()));
332        }
333    }
334    Ok(())
335}
336
337fn copy_workspace(src: &Path, dst: &Path) -> Result<(), SandboxError> {
338    for entry in fs::read_dir(src).map_err(io_err)? {
339        let entry = entry.map_err(io_err)?;
340        let source_path = entry.path();
341        let target_path = dst.join(entry.file_name());
342        let file_type = entry.file_type().map_err(io_err)?;
343        if should_skip(&source_path) {
344            continue;
345        }
346        if file_type.is_dir() {
347            fs::create_dir_all(&target_path).map_err(io_err)?;
348            copy_workspace(&source_path, &target_path)?;
349        } else if file_type.is_file() {
350            fs::copy(&source_path, &target_path).map_err(io_err)?;
351        }
352    }
353    Ok(())
354}
355
356fn should_skip(path: &Path) -> bool {
357    path.file_name()
358        .and_then(|name| name.to_str())
359        // Skip generated artifact roots to keep sandbox copies bounded.
360        .map(|name| matches!(name, ".git" | "target" | "demo_runs"))
361        .unwrap_or(false)
362}
363
364fn truncate_to_limit(bytes: &[u8], max_output_bytes: usize) -> String {
365    let limit = bytes.len().min(max_output_bytes);
366    String::from_utf8_lossy(&bytes[..limit]).into_owned()
367}
368
369fn hash_patch(payload: &str) -> String {
370    let mut hasher = Sha256::new();
371    hasher.update(payload.as_bytes());
372    hex::encode(hasher.finalize())
373}
374
375fn io_err(err: std::io::Error) -> SandboxError {
376    SandboxError::Io(err.to_string())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use oris_evolution::{MutationArtifact, MutationIntent, RiskLevel};
383
384    fn temp_workspace(name: &str) -> PathBuf {
385        let path = std::env::temp_dir().join(format!("oris-sandbox-{name}-{}", std::process::id()));
386        if path.exists() {
387            fs::remove_dir_all(&path).unwrap();
388        }
389        fs::create_dir_all(path.join("src")).unwrap();
390        fs::write(path.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
391        path
392    }
393
394    #[tokio::test]
395    async fn command_runner_rejects_non_allowlisted_program() {
396        let workspace = temp_workspace("policy");
397        let policy = SandboxPolicy {
398            allowed_programs: vec!["git".into()],
399            max_duration_ms: 1_000,
400            max_output_bytes: 1024,
401            denied_env_prefixes: Vec::new(),
402            max_memory_bytes: None,
403            max_cpu_secs: None,
404            use_process_group: false,
405        };
406        let result = execute_allowed_command(&policy, &workspace, "cargo", &[], 1_000).await;
407        assert!(matches!(result, Err(SandboxError::CommandDenied(_))));
408    }
409
410    #[tokio::test]
411    async fn sandbox_rejects_patch_outside_allowlist() {
412        let workspace = temp_workspace("target");
413        let sandbox = LocalProcessSandbox::new("run-1", &workspace, std::env::temp_dir());
414        let mutation = PreparedMutation {
415            intent: MutationIntent {
416                id: "mutation-1".into(),
417                intent: "touch manifest".into(),
418                target: MutationTarget::Paths {
419                    allow: vec!["src".into()],
420                },
421                expected_effect: "should fail".into(),
422                risk: RiskLevel::Low,
423                signals: vec!["signal".into()],
424                spec_id: None,
425            },
426            artifact: MutationArtifact {
427                encoding: ArtifactEncoding::UnifiedDiff,
428                payload: "\
429diff --git a/Cargo.toml b/Cargo.toml
430new file mode 100644
431index 0000000..1111111
432--- /dev/null
433+++ b/Cargo.toml
434@@ -0,0 +1 @@
435+[package]
436"
437                .into(),
438                base_revision: Some("HEAD".into()),
439                content_hash: "hash".into(),
440            },
441        };
442        let result = sandbox.apply(&mutation, &SandboxPolicy::default()).await;
443        assert!(matches!(result, Err(SandboxError::TargetViolation(_))));
444    }
445
446    #[test]
447    fn should_skip_generated_artifact_roots() {
448        assert!(should_skip(Path::new(".git")));
449        assert!(should_skip(Path::new("target")));
450        assert!(should_skip(Path::new("demo_runs")));
451        assert!(!should_skip(Path::new("docs")));
452    }
453}