gitdataai/libs/agent/orao/act.rs

194 lines
6.9 KiB
Rust

//! 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<dyn Fn(PlannedAction) -> Pin<Box<dyn Future<Output = ActionResult> + 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<String> {
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<String> {
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
}