1use std::error::Error;
9use std::ffi::OsString;
10use std::fmt;
11use std::fs;
12use std::io;
13use std::path::{Component, Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use crate::engine::stable_id;
20
21static WORKSPACE_SEQ: AtomicU64 = AtomicU64::new(0);
24
25const DEFAULT_AGENT_TIME_BUDGET: Duration = Duration::from_secs(2);
26const WINDOWS_PYTHON_TIME_BUDGET_FLOOR: Duration = Duration::from_secs(15);
27
28#[derive(Debug, Clone)]
29pub struct AgentWorkspaceConfig {
30 pub base_dir: PathBuf,
31 pub time_budget: Duration,
32}
33
34impl Default for AgentWorkspaceConfig {
35 fn default() -> Self {
36 Self {
37 base_dir: std::env::temp_dir().join("formal-ai-agent-workspaces"),
38 time_budget: DEFAULT_AGENT_TIME_BUDGET,
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct AgentRun {
45 pub workspace: PathBuf,
46 pub status: AgentRunStatus,
47 pub actions: Vec<AgentAction>,
48 pub command_results: Vec<AgentCommandResult>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum AgentRunStatus {
53 Completed,
54 Failed,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct AgentAction {
59 pub kind: AgentActionKind,
60 pub target: String,
61 pub status: AgentActionStatus,
62 pub detail: String,
63}
64
65impl AgentAction {
66 #[must_use]
67 pub const fn event_kind(&self) -> &'static str {
68 match self.kind {
69 AgentActionKind::CreateFile => "action_log:create_file",
70 AgentActionKind::ModifyFile => "action_log:modify_file",
71 AgentActionKind::DeleteFile => "action_log:delete_file",
72 AgentActionKind::RunCommand => "action_log:run_command",
73 }
74 }
75
76 #[must_use]
77 pub fn evidence_payload(&self) -> String {
78 format!("{} {} {}", self.target, self.status.slug(), self.detail)
79 }
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum AgentActionKind {
84 CreateFile,
85 ModifyFile,
86 DeleteFile,
87 RunCommand,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum AgentActionStatus {
92 Completed,
93 Failed,
94}
95
96impl AgentActionStatus {
97 #[must_use]
98 pub const fn slug(self) -> &'static str {
99 match self {
100 Self::Completed => "completed",
101 Self::Failed => "failed",
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct AgentCommandResult {
108 pub command: String,
109 pub status_code: Option<i32>,
110 pub stdout: String,
111 pub stderr: String,
112 pub timed_out: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum PlannedAgentAction {
117 CreateFile { path: String, content: String },
118 ModifyFile { path: String, content: String },
119 DeleteFile { path: String },
120 RunCommand { command: String },
121}
122
123#[derive(Debug)]
124pub enum AgentError {
125 Io(io::Error),
126 EmptyCommand,
127 UnsupportedCommand(String),
128 PathEscapesWorkspace(String),
129}
130
131impl fmt::Display for AgentError {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Io(error) => write!(formatter, "{error}"),
135 Self::EmptyCommand => write!(formatter, "command is empty"),
136 Self::UnsupportedCommand(command) => {
137 write!(formatter, "unsupported sandbox command `{command}`")
138 }
139 Self::PathEscapesWorkspace(path) => {
140 write!(formatter, "path `{path}` escapes the isolated workspace")
141 }
142 }
143 }
144}
145
146impl Error for AgentError {}
147
148impl From<io::Error> for AgentError {
149 fn from(error: io::Error) -> Self {
150 Self::Io(error)
151 }
152}
153
154pub struct AgentWorkspace {
155 root: PathBuf,
156 time_budget: Duration,
157 actions: Vec<AgentAction>,
158 command_results: Vec<AgentCommandResult>,
159 failed: bool,
160}
161
162impl AgentWorkspace {
163 pub fn for_prompt(prompt: &str, config: &AgentWorkspaceConfig) -> Result<Self, AgentError> {
164 let prefix = stable_id("agent_workspace", prompt);
170 let seq = WORKSPACE_SEQ.fetch_add(1, Ordering::Relaxed);
171 let workspace_id = format!("{prefix}-{}-{seq}", std::process::id());
172 let root = config.base_dir.join(workspace_id);
173 if root.exists() {
174 fs::remove_dir_all(&root)?;
175 }
176 fs::create_dir_all(&root)?;
177 Ok(Self {
178 root,
179 time_budget: config.time_budget,
180 actions: Vec::new(),
181 command_results: Vec::new(),
182 failed: false,
183 })
184 }
185
186 #[must_use]
187 pub fn root(&self) -> &Path {
188 &self.root
189 }
190
191 #[must_use]
198 pub fn last_command_result(&self) -> Option<&AgentCommandResult> {
199 self.command_results.last()
200 }
201
202 pub fn create_file(&mut self, path: &str, content: &str) {
203 let result = self.write_file(path, content);
204 self.record_fs_action(AgentActionKind::CreateFile, path, result);
205 }
206
207 pub fn modify_file(&mut self, path: &str, content: &str) {
208 let result = self.write_file(path, content);
209 self.record_fs_action(AgentActionKind::ModifyFile, path, result);
210 }
211
212 pub fn delete_file(&mut self, path: &str) {
213 let result = self
214 .workspace_path(path)
215 .and_then(|resolved| fs::remove_file(resolved).map_err(AgentError::from));
216 self.record_fs_action(AgentActionKind::DeleteFile, path, result.map(|()| 0));
217 }
218
219 pub fn run_command(&mut self, command_line: &str) {
220 let result = self.run_command_inner(command_line);
221 match result {
222 Ok(command_result) => {
223 let status = if command_result.timed_out || command_result.status_code != Some(0) {
224 self.failed = true;
225 AgentActionStatus::Failed
226 } else {
227 AgentActionStatus::Completed
228 };
229 self.actions.push(AgentAction {
230 kind: AgentActionKind::RunCommand,
231 target: command_line.to_owned(),
232 status,
233 detail: format!(
234 "exit={:?} stdout_bytes={} stderr_bytes={}",
235 command_result.status_code,
236 command_result.stdout.len(),
237 command_result.stderr.len()
238 ),
239 });
240 self.command_results.push(command_result);
241 }
242 Err(error) => {
243 self.failed = true;
244 self.actions.push(AgentAction {
245 kind: AgentActionKind::RunCommand,
246 target: command_line.to_owned(),
247 status: AgentActionStatus::Failed,
248 detail: error.to_string(),
249 });
250 }
251 }
252 }
253
254 #[must_use]
255 pub fn finish(self) -> AgentRun {
256 AgentRun {
257 workspace: self.root,
258 status: if self.failed {
259 AgentRunStatus::Failed
260 } else {
261 AgentRunStatus::Completed
262 },
263 actions: self.actions,
264 command_results: self.command_results,
265 }
266 }
267
268 fn write_file(&self, path: &str, content: &str) -> Result<usize, AgentError> {
269 let resolved = self.workspace_path(path)?;
270 if let Some(parent) = resolved.parent() {
271 fs::create_dir_all(parent)?;
272 }
273 fs::write(resolved, content)?;
274 Ok(content.len())
275 }
276
277 fn record_fs_action(
278 &mut self,
279 kind: AgentActionKind,
280 target: &str,
281 result: Result<usize, AgentError>,
282 ) {
283 match result {
284 Ok(bytes) => self.actions.push(AgentAction {
285 kind,
286 target: target.to_owned(),
287 status: AgentActionStatus::Completed,
288 detail: format!("bytes={bytes}"),
289 }),
290 Err(error) => {
291 self.failed = true;
292 self.actions.push(AgentAction {
293 kind,
294 target: target.to_owned(),
295 status: AgentActionStatus::Failed,
296 detail: error.to_string(),
297 });
298 }
299 }
300 }
301
302 fn workspace_path(&self, path: &str) -> Result<PathBuf, AgentError> {
303 let candidate = Path::new(path.trim());
304 if candidate.as_os_str().is_empty() || candidate.is_absolute() {
305 return Err(AgentError::PathEscapesWorkspace(path.to_owned()));
306 }
307 for component in candidate.components() {
308 if !matches!(component, Component::Normal(_)) {
309 return Err(AgentError::PathEscapesWorkspace(path.to_owned()));
310 }
311 }
312 Ok(self.root.join(candidate))
313 }
314
315 fn run_command_inner(&self, command_line: &str) -> Result<AgentCommandResult, AgentError> {
316 let parts = split_command(command_line);
317 let Some((program, args)) = parts.split_first() else {
318 return Err(AgentError::EmptyCommand);
319 };
320 for argument in args {
321 if looks_like_workspace_path(argument) {
322 self.workspace_path(argument)?;
323 }
324 }
325 if let Some(result) = self.run_builtin_command(command_line, program, args)? {
326 return Ok(result);
327 }
328 let command_budget = effective_command_time_budget(program, self.time_budget);
329 let program_path = resolve_allowed_program(program)?;
330 let mut child = Command::new(program_path)
331 .args(args)
332 .current_dir(&self.root)
333 .env_clear()
334 .stdin(Stdio::null())
335 .stdout(Stdio::piped())
336 .stderr(Stdio::piped())
337 .spawn()?;
338 let started = Instant::now();
339 let timed_out = loop {
340 if child.try_wait()?.is_some() {
341 break false;
342 }
343 if started.elapsed() >= command_budget {
344 child.kill()?;
345 break true;
346 }
347 thread::sleep(Duration::from_millis(10));
348 };
349 let output = child.wait_with_output()?;
350 Ok(AgentCommandResult {
351 command: command_line.to_owned(),
352 status_code: output.status.code(),
353 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
354 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
355 timed_out,
356 })
357 }
358
359 fn run_builtin_command(
360 &self,
361 command_line: &str,
362 program: &str,
363 args: &[String],
364 ) -> Result<Option<AgentCommandResult>, AgentError> {
365 let stdout = match program {
366 "env" => String::new(),
367 "cat" if cfg!(windows) => self.read_command_files(args)?,
368 "ls" if cfg!(windows) => self.list_command_directory(args)?,
369 "printf" if cfg!(windows) => args.join(" "),
370 _ => return Ok(None),
371 };
372 Ok(Some(AgentCommandResult {
373 command: command_line.to_owned(),
374 status_code: Some(0),
375 stdout,
376 stderr: String::new(),
377 timed_out: false,
378 }))
379 }
380
381 fn read_command_files(&self, args: &[String]) -> Result<String, AgentError> {
382 let mut output = String::new();
383 for argument in args {
384 if argument.starts_with('-') {
385 continue;
386 }
387 output.push_str(&fs::read_to_string(self.workspace_path(argument)?)?);
388 }
389 Ok(output)
390 }
391
392 fn list_command_directory(&self, args: &[String]) -> Result<String, AgentError> {
393 let directory = args
394 .iter()
395 .find(|argument| !argument.starts_with('-'))
396 .map_or_else(
397 || Ok(self.root.clone()),
398 |argument| self.workspace_path(argument),
399 )?;
400 let mut entries = fs::read_dir(directory)?
401 .map(|entry| entry.map(|entry| entry.file_name().to_string_lossy().into_owned()))
402 .collect::<Result<Vec<_>, _>>()?;
403 entries.sort();
404 let mut output = entries.join("\n");
405 if !output.is_empty() {
406 output.push('\n');
407 }
408 Ok(output)
409 }
410}
411
412#[must_use]
413pub fn parse_agent_plan(prompt: &str) -> Vec<PlannedAgentAction> {
414 let lower = prompt.to_lowercase();
415 let mut indexed = Vec::new();
416 collect_create_actions(prompt, &lower, &mut indexed);
417 collect_modify_actions(prompt, &lower, &mut indexed);
418 collect_delete_actions(prompt, &lower, &mut indexed);
419 collect_command_actions(prompt, &lower, &mut indexed);
420 indexed.sort_by_key(|(index, _)| *index);
421 indexed.into_iter().map(|(_, action)| action).collect()
422}
423
424pub fn run_agent_plan(prompt: &str, config: &AgentWorkspaceConfig) -> Result<AgentRun, AgentError> {
425 let plan = parse_agent_plan(prompt);
426 let mut workspace = AgentWorkspace::for_prompt(prompt, config)?;
427 for action in plan {
428 match action {
429 PlannedAgentAction::CreateFile { path, content } => {
430 workspace.create_file(&path, &content);
431 }
432 PlannedAgentAction::ModifyFile { path, content } => {
433 workspace.modify_file(&path, &content);
434 }
435 PlannedAgentAction::DeleteFile { path } => {
436 workspace.delete_file(&path);
437 }
438 PlannedAgentAction::RunCommand { command } => {
439 workspace.run_command(&command);
440 }
441 }
442 }
443 Ok(workspace.finish())
444}
445
446fn collect_create_actions(
447 prompt: &str,
448 lower: &str,
449 indexed: &mut Vec<(usize, PlannedAgentAction)>,
450) {
451 let marker = "create file ";
452 let mut offset = 0;
453 while let Some(relative) = lower[offset..].find(marker) {
454 let index = offset + relative;
455 let path_start = index + marker.len();
456 let Some(with_relative) = lower[path_start..].find(" with ") else {
457 offset = path_start;
458 continue;
459 };
460 let path_end = path_start + with_relative;
461 let Some(content) = backticked_after(prompt, path_end) else {
462 offset = path_start;
463 continue;
464 };
465 let path = prompt[path_start..path_end].trim();
466 if !path.is_empty() {
467 indexed.push((
468 index,
469 PlannedAgentAction::CreateFile {
470 path: path.to_owned(),
471 content,
472 },
473 ));
474 }
475 offset = path_end;
476 }
477}
478
479fn collect_modify_actions(
480 prompt: &str,
481 lower: &str,
482 indexed: &mut Vec<(usize, PlannedAgentAction)>,
483) {
484 let marker = "modify ";
485 let mut offset = 0;
486 while let Some(relative) = lower[offset..].find(marker) {
487 let index = offset + relative;
488 let mut path_start = index + marker.len();
489 if lower[path_start..].starts_with("file ") {
490 path_start += "file ".len();
491 }
492 let Some(to_relative) = lower[path_start..].find(" to ") else {
493 offset = path_start;
494 continue;
495 };
496 let path_end = path_start + to_relative;
497 let Some(content) = backticked_after(prompt, path_end) else {
498 offset = path_start;
499 continue;
500 };
501 let path = prompt[path_start..path_end].trim();
502 if !path.is_empty() {
503 indexed.push((
504 index,
505 PlannedAgentAction::ModifyFile {
506 path: path.to_owned(),
507 content,
508 },
509 ));
510 }
511 offset = path_end;
512 }
513}
514
515fn collect_delete_actions(
516 prompt: &str,
517 lower: &str,
518 indexed: &mut Vec<(usize, PlannedAgentAction)>,
519) {
520 for marker in ["delete file ", "delete "] {
521 let mut offset = 0;
522 while let Some(relative) = lower[offset..].find(marker) {
523 let index = offset + relative;
524 if marker == "delete " && lower[index..].starts_with("delete file ") {
525 offset = index + "delete ".len();
526 continue;
527 }
528 let path_start = index + marker.len();
529 let tail = &prompt[path_start..];
530 let path_end = path_start + delete_path_len(tail);
531 let path = prompt[path_start..path_end].trim();
532 if !path.is_empty() {
533 indexed.push((
534 index,
535 PlannedAgentAction::DeleteFile {
536 path: path.to_owned(),
537 },
538 ));
539 }
540 offset = path_end;
541 }
542 }
543}
544
545fn collect_command_actions(
546 prompt: &str,
547 lower: &str,
548 indexed: &mut Vec<(usize, PlannedAgentAction)>,
549) {
550 for marker in ["run terminal command ", "run command ", "run "] {
551 let mut offset = 0;
552 while let Some(relative) = lower[offset..].find(marker) {
553 let index = offset + relative;
554 if marker == "run "
555 && (lower[index..].starts_with("run command ")
556 || lower[index..].starts_with("run terminal command "))
557 {
558 offset = index + marker.len();
559 continue;
560 }
561 let Some(command) = backticked_after(prompt, index + marker.len()) else {
562 offset = index + marker.len();
563 continue;
564 };
565 if !command.trim().is_empty() {
566 indexed.push((
567 index,
568 PlannedAgentAction::RunCommand {
569 command: command.trim().to_owned(),
570 },
571 ));
572 }
573 offset = index + marker.len();
574 }
575 }
576}
577
578fn backticked_after(prompt: &str, start: usize) -> Option<String> {
579 let relative_open = prompt[start..].find('`')?;
580 let open = start + relative_open + 1;
581 let relative_close = prompt[open..].find('`')?;
582 Some(prompt[open..open + relative_close].to_owned())
583}
584
585fn delete_path_len(tail: &str) -> usize {
586 let mut end = tail.len();
587 for delimiter in [",", ";", " and ", " then "] {
588 if let Some(index) = tail.find(delimiter) {
589 end = end.min(index);
590 }
591 }
592 end
593}
594
595fn split_command(command_line: &str) -> Vec<String> {
596 command_line
597 .split_whitespace()
598 .map(str::trim)
599 .filter(|part| !part.is_empty())
600 .map(|part| part.trim_matches(['"', '\'']).to_owned())
601 .collect()
602}
603
604fn resolve_allowed_program(program: &str) -> Result<PathBuf, AgentError> {
605 let candidates: &[&str] = match program {
606 "cat" => &["/bin/cat", "/usr/bin/cat"],
607 "ls" => &["/bin/ls", "/usr/bin/ls"],
608 "printf" => &["/usr/bin/printf", "/bin/printf"],
609 "env" => &["/usr/bin/env", "/bin/env"],
610 "python3" => &["/usr/bin/python3", "/bin/python3", "/usr/local/bin/python3"],
611 other => return Err(AgentError::UnsupportedCommand(other.to_owned())),
612 };
613 candidates
614 .iter()
615 .map(PathBuf::from)
616 .find(|path| path.is_file())
617 .or_else(|| resolve_allowed_program_from_path(program))
618 .ok_or_else(|| AgentError::UnsupportedCommand(program.to_owned()))
619}
620
621fn resolve_allowed_program_from_path(program: &str) -> Option<PathBuf> {
622 let names = path_search_names(program)?;
623 resolve_program_from_path_names(names, std::env::var_os("PATH"))
624}
625
626fn resolve_program_from_path_names(names: &[&str], path: Option<OsString>) -> Option<PathBuf> {
627 let path = path?;
628 let directories: Vec<_> = std::env::split_paths(&path)
629 .filter(|directory| directory.is_absolute())
630 .collect();
631 names
632 .iter()
633 .flat_map(|name| {
634 directories
635 .iter()
636 .map(move |directory| directory.join(name))
637 })
638 .find(|candidate| candidate.is_file() && !is_blocked_execution_alias(candidate))
639}
640
641fn path_search_names(program: &str) -> Option<&'static [&'static str]> {
642 match program {
643 "python3" if cfg!(windows) => Some(&["python3.exe", "python.exe", "py.exe"]),
644 "python3" => Some(&["python3"]),
645 _ => None,
646 }
647}
648
649fn is_blocked_execution_alias(candidate: &Path) -> bool {
650 cfg!(windows)
651 && candidate
652 .to_string_lossy()
653 .to_ascii_lowercase()
654 .contains(r"\microsoft\windowsapps\")
655}
656
657fn effective_command_time_budget(program: &str, configured: Duration) -> Duration {
658 if cfg!(windows) && program == "python3" && configured < WINDOWS_PYTHON_TIME_BUDGET_FLOOR {
659 WINDOWS_PYTHON_TIME_BUDGET_FLOOR
660 } else {
661 configured
662 }
663}
664
665fn looks_like_workspace_path(argument: &str) -> bool {
666 !argument.starts_with('-')
667 && !argument.contains('=')
668 && (argument.contains('/')
669 || argument.contains('.')
670 || Path::new(argument).extension().is_some())
671}