1use 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 #[serde(default)]
25 pub max_memory_bytes: Option<u64>,
26 #[serde(default)]
30 pub max_cpu_secs: Option<u64>,
31 #[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 #[cfg(all(feature = "resource-limits", target_os = "linux"))]
246 crate::resource_limits::apply_linux_limits(&mut command, policy);
247
248 #[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 .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}