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