//! Act phase: execute planned actions with safety checks. //! //! Actions are executed through a caller-provided executor callback, which //! typically dispatches to the [`ToolRegistry`] or runs shell commands. //! All file access must go through function calls (tools), never direct //! filesystem operations. //! //! [`ToolRegistry`]: crate::tool::ToolRegistry use std::future::Future; use std::pin::Pin; use std::process::Command; use std::time::Duration; use super::types::{ ActionResult, ActionType, ActionVerdict, OraoConfig, PlannedAction, SafetyLevel, }; /// Callback for executing a planned action. /// /// The caller (service layer) provides this to wire up tool execution. /// Returns `ActionResult` on completion. pub type ActionExecutor = Box Pin + Send>> + Send + Sync>; /// Check whether an action is allowed under the given safety configuration. /// /// Returns `None` if allowed, or `Some(reason)` if blocked. pub fn check_safety(action: &PlannedAction, config: &OraoConfig) -> Option { let safety = SafetyLevel::classify_command(&action.command_or_content); if safety > config.max_safety_level { return Some(format!( "Action denied: safety level {:?} exceeds max allowed {:?}", safety, config.max_safety_level )); } // Check for dangerous command patterns if let Some(reason) = check_dangerous_command(&action.command_or_content) { return Some(reason); } None } /// Execute a single planned action via the provided executor. /// /// Applies safety checks and timeout, then delegates to the executor. pub async fn execute_action( action: PlannedAction, config: &OraoConfig, executor: &ActionExecutor, ) -> ActionResult { // ── Safety gate ──────────────────────────────────────────────────── if let Some(reason) = check_safety(&action, config) { return ActionResult { action, exit_code: Some(1), stdout: String::new(), stderr: reason, file_changes: Vec::new(), verdict: ActionVerdict::Failure, }; } // ── Execute with timeout ────────────────────────────────────────── let action_clone = action.clone(); let exec_future = executor(action); match tokio::time::timeout(Duration::from_secs(config.action_timeout_secs), exec_future).await { Ok(result) => result, Err(_elapsed) => ActionResult { action: action_clone, exit_code: None, stdout: String::new(), stderr: format!( "Action timed out after {} seconds", config.action_timeout_secs ), file_changes: Vec::new(), verdict: ActionVerdict::Failure, }, } } /// Build a default action executor that runs shell commands directly. /// /// This is suitable for `shell_command` and `git_operation` action types. /// For `tool_invoke`, the caller should provide a custom executor that /// dispatches to the [`ToolRegistry`]. /// /// [`ToolRegistry`]: crate::tool::ToolRegistry pub fn shell_executor(working_dir: String) -> ActionExecutor { Box::new(move |action: PlannedAction| { let dir = working_dir.clone(); Box::pin(async move { match action.action_type { ActionType::ShellCommand | ActionType::GitOperation | ActionType::ToolInvoke => { run_shell_command(&action, &dir).await } ActionType::FileWrite | ActionType::FileEdit => { // File operations should use tool_invoke with a file-writing tool. // Direct file access is discouraged; return an error directing to tools. ActionResult { exit_code: Some(1), stdout: String::new(), stderr: "File operations must use tool_invoke with registered file tools. Use shell_command with sed/echo for inline edits.".to_string(), file_changes: Vec::new(), verdict: ActionVerdict::Failure, action, } } ActionType::UserDialog => ActionResult { exit_code: None, stdout: "User dialog requested".to_string(), stderr: String::new(), file_changes: Vec::new(), verdict: ActionVerdict::Success, action, }, } }) }) } async fn run_shell_command(action: &PlannedAction, working_dir: &str) -> ActionResult { let cmd = &action.command_or_content; let output = Command::new("sh") .args(["-c", cmd]) .current_dir(working_dir) .output(); match output { Ok(out) => { let exit_code = out.status.code(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); let verdict = match exit_code { Some(0) if !stderr_has_errors(&stderr) => ActionVerdict::Success, Some(0) => ActionVerdict::SuccessWithWarnings, _ => ActionVerdict::Failure, }; ActionResult { action: action.clone(), exit_code, stdout, stderr, file_changes: Vec::new(), verdict, } } Err(e) => ActionResult { action: action.clone(), exit_code: None, stdout: String::new(), stderr: format!("Failed to spawn command: {}", e), file_changes: Vec::new(), verdict: ActionVerdict::Failure, }, } } fn stderr_has_errors(stderr: &str) -> bool { let lower = stderr.to_lowercase(); lower.contains("error") || lower.contains("fail") || lower.contains("panic") } /// Check whether a shell command contains dangerous patterns. /// /// Returns `Some(reason)` if the command is blocked, `None` if it's safe. pub fn check_dangerous_command(cmd: &str) -> Option { let dangerous = [ ("rm -rf /", "Recursive root deletion"), ("rm -rf ~", "Recursive home deletion"), (":(){ :|:& };:", "Fork bomb"), ("mkfs.", "Filesystem format"), ("dd if=", "Raw device write"), ("> /dev/sda", "Raw device write"), ("chmod 777 /", "World-writable root"), ]; for (pattern, reason) in &dangerous { if cmd.contains(pattern) { return Some(format!("Blocked: {} — {}", pattern, reason)); } } None }