Skip to main content

rucora_tools/system/
cmd_exec.rs

1//! 受限命令执行工具
2//!
3//! 该工具用于执行**受限**的命令行指令。
4//!
5//! 设计目标:
6//! - 允许 Agent 在可控范围内调用外部命令(当前默认仅允许 `curl`)
7//! - 通过白名单 + 禁止 shell 操作符来降低注入与破坏性风险
8//! - 对输出进行截断,避免返回内容过大
9
10use async_trait::async_trait;
11use rucora_core::{
12    error::ToolError,
13    tool::{Tool, ToolCategory},
14};
15use serde_json::{Value, json};
16
17use super::shell::{SHELL_TIMEOUT_SECS, execute_shell_command};
18
19/// 受限命令执行工具。
20///
21/// 当前实现默认仅允许执行 `curl`(包含 `curl.exe`),并禁止常见 shell 操作符。
22pub struct CmdExecTool {
23    /// 允许的命令前缀白名单。
24    ///
25    /// 只要输入命令行以任一前缀开头(或 `"{prefix} "` 开头),即视为允许。
26    pub allowed_prefixes: &'static [&'static str],
27}
28
29impl CmdExecTool {
30    /// 创建一个默认的 `CmdExecTool`。
31    ///
32    /// 默认白名单为:`curl` / `curl.exe`。
33    pub fn new() -> Self {
34        Self {
35            allowed_prefixes: &["curl", "curl.exe"],
36        }
37    }
38
39    /// 校验命令行是否符合安全约束。
40    ///
41    /// - 必须以白名单前缀开头
42    /// - 禁止管道/重定向/链式/多行等 shell 操作符
43    fn validate_command(&self, cmd: &str) -> Result<(), ToolError> {
44        let t = cmd.trim();
45        let prefix_ok = self
46            .allowed_prefixes
47            .iter()
48            .any(|p| t == *p || t.starts_with(&format!("{p} ")));
49
50        if !prefix_ok {
51            return Err(ToolError::Message(
52                "出于安全考虑,cmd_exec 目前仅允许执行 curl 命令".to_string(),
53            ));
54        }
55
56        let forbidden = ["|", "&&", ";", ">", "<", "`", "$ (", "$(", "\n", "\r"];
57        if forbidden.iter().any(|x| t.contains(x)) {
58            return Err(ToolError::Message(
59                "出于安全考虑,cmd_exec 禁止管道/重定向/链式/多行命令".to_string(),
60            ));
61        }
62
63        Ok(())
64    }
65}
66
67impl Default for CmdExecTool {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73#[async_trait]
74impl Tool for CmdExecTool {
75    /// 工具名称(用于让模型在 tool_call 中引用)。
76    fn name(&self) -> &str {
77        "cmd_exec"
78    }
79
80    /// 工具描述(会作为 tool/function 定义提供给模型)。
81    fn description(&self) -> Option<&str> {
82        Some("执行受限的命令行(当前仅允许 curl)")
83    }
84
85    /// 工具分类。
86    fn categories(&self) -> &'static [ToolCategory] {
87        &[ToolCategory::System]
88    }
89
90    /// 输入 schema(JSON Schema)。
91    ///
92    /// - `command`:要执行的一整行命令
93    /// - `timeout`:可选超时时间(秒)
94    fn input_schema(&self) -> Value {
95        json!({
96            "type": "object",
97            "properties": {
98                "command": {
99                    "type": "string",
100                    "description": "要执行的命令行(仅允许以 curl 开头)"
101                },
102                "timeout": {
103                    "type": "integer",
104                    "description": "超时时间(秒),默认 60 秒",
105                    "default": 60
106                }
107            },
108            "required": ["command"]
109        })
110    }
111
112    async fn call(&self, input: Value) -> Result<Value, ToolError> {
113        let command = input
114            .get("command")
115            .and_then(|v| v.as_str())
116            .ok_or_else(|| ToolError::Message("缺少必需的 'command' 字段".to_string()))?;
117
118        let timeout_secs = input
119            .get("timeout")
120            .and_then(|v| v.as_u64())
121            .unwrap_or(SHELL_TIMEOUT_SECS);
122
123        // 校验命令
124        self.validate_command(command)?;
125
126        // 执行命令
127        let mut parts = command.split_whitespace();
128        let executable = parts
129            .next()
130            .ok_or_else(|| ToolError::Message("命令不能为空".to_string()))?;
131        let args: Vec<String> = parts.map(String::from).collect();
132        let result = execute_shell_command(executable, &args, timeout_secs, None).await?;
133
134        Ok(json!({
135            "command": command,
136            "stdout": result.stdout,
137            "stderr": result.stderr,
138            "exit_code": result.exit_code,
139            "success": result.exit_code == 0,
140            "truncated": result.truncated
141        }))
142    }
143}