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}
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}