Skip to main content

opendev_repl/handlers/
process_handler.rs

1//! Handler for bash/process execution.
2//!
3//! Mirrors `opendev/core/context_engineering/tools/handlers/process_handlers.py`.
4//!
5//! Responsibilities:
6//! - Detect server/long-running commands and promote to background
7//! - Check command approval rules
8//! - Truncate output for display
9//! - Track operations for audit
10
11use std::collections::HashMap;
12
13use regex::Regex;
14use serde_json::Value;
15use tracing::debug;
16
17use opendev_runtime::approval::ApprovalRulesManager;
18
19use super::traits::{HandlerMeta, HandlerResult, PreCheckResult, ToolHandler};
20
21/// Maximum output lines before truncation.
22const MAX_OUTPUT_LINES: usize = 200;
23
24/// Patterns that indicate a server/long-running process.
25const SERVER_PATTERNS: &[&str] = &[
26    r"flask\s+run",
27    r"python.*app\.py",
28    r"python.*manage\.py\s+runserver",
29    r"django.*runserver",
30    r"uvicorn",
31    r"gunicorn",
32    r"python.*-m\s+http\.server",
33    r"npm\s+(run\s+)?(start|dev|serve)",
34    r"yarn\s+(run\s+)?(start|dev|serve)",
35    r"node.*server",
36    r"nodemon",
37    r"next\s+(dev|start)",
38    r"rails\s+server",
39    r"php.*artisan\s+serve",
40    r"hugo\s+server",
41    r"jekyll\s+serve",
42    r"cargo\s+run",
43    r"go\s+run",
44];
45
46/// Handler for bash/process tool execution.
47pub struct ProcessHandler {
48    approval_manager: Option<ApprovalRulesManager>,
49    server_re: Vec<Regex>,
50}
51
52impl ProcessHandler {
53    /// Create a new process handler.
54    pub fn new(approval_manager: Option<ApprovalRulesManager>) -> Self {
55        let server_re = SERVER_PATTERNS
56            .iter()
57            .filter_map(|p| Regex::new(p).ok())
58            .collect();
59        Self {
60            approval_manager,
61            server_re,
62        }
63    }
64
65    /// Check if a command looks like a server/long-running process.
66    fn is_server_command(&self, command: &str) -> bool {
67        self.server_re.iter().any(|re| re.is_match(command))
68    }
69
70    /// Truncate output to MAX_OUTPUT_LINES, preserving head and tail.
71    fn truncate_output(output: &str) -> String {
72        let lines: Vec<&str> = output.lines().collect();
73        if lines.len() <= MAX_OUTPUT_LINES {
74            return output.to_string();
75        }
76
77        let head = MAX_OUTPUT_LINES * 2 / 3;
78        let tail = MAX_OUTPUT_LINES - head - 1;
79        let omitted = lines.len() - head - tail;
80
81        let mut result = lines[..head].join("\n");
82        result.push_str(&format!("\n\n... ({omitted} lines omitted) ...\n\n"));
83        result.push_str(&lines[lines.len() - tail..].join("\n"));
84        result
85    }
86}
87
88impl ToolHandler for ProcessHandler {
89    fn handles(&self) -> &[&str] {
90        &["Bash", "bash"]
91    }
92
93    fn pre_check(&self, _tool_name: &str, args: &HashMap<String, Value>) -> PreCheckResult {
94        let command = match args.get("command").and_then(|v| v.as_str()) {
95            Some(cmd) => cmd,
96            None => return PreCheckResult::Deny("Missing 'command' argument".to_string()),
97        };
98
99        // Check approval rules if manager is available.
100        if let Some(ref mgr) = self.approval_manager
101            && let Some(rule) = mgr.evaluate_command(command)
102        {
103            use opendev_runtime::approval::RuleAction;
104            match rule.action {
105                RuleAction::AutoDeny => {
106                    return PreCheckResult::Deny(format!(
107                        "Command denied by rule: {}",
108                        &rule.description,
109                    ));
110                }
111                RuleAction::AutoApprove => {
112                    debug!(command, "Command auto-approved by rule");
113                }
114                _ => {} // RequireApproval, RequireEdit handled upstream
115            }
116        }
117
118        // Auto-promote server commands to background.
119        let background = args
120            .get("background")
121            .and_then(|v| v.as_bool())
122            .unwrap_or(false);
123        if !background && self.is_server_command(command) {
124            debug!(command, "Auto-promoting server command to background");
125            let mut new_args = args.clone();
126            new_args.insert("background".to_string(), Value::Bool(true));
127            return PreCheckResult::ModifyArgs(new_args);
128        }
129
130        PreCheckResult::Allow
131    }
132
133    fn post_process(
134        &self,
135        _tool_name: &str,
136        args: &HashMap<String, Value>,
137        output: Option<&str>,
138        error: Option<&str>,
139        success: bool,
140    ) -> HandlerResult {
141        let is_background = args
142            .get("background")
143            .and_then(|v| v.as_bool())
144            .unwrap_or(false);
145
146        let truncated_output = output.map(Self::truncate_output);
147
148        HandlerResult {
149            output: truncated_output,
150            error: error.map(|s| s.to_string()),
151            success,
152            meta: HandlerMeta {
153                is_background,
154                operation_id: Some(format!(
155                    "bash_{}",
156                    std::time::SystemTime::now()
157                        .duration_since(std::time::UNIX_EPOCH)
158                        .map(|d| d.as_millis())
159                        .unwrap_or(0)
160                )),
161                ..Default::default()
162            },
163        }
164    }
165}
166
167#[cfg(test)]
168#[path = "process_handler_tests.rs"]
169mod tests;