Skip to main content

microclaw_engine/internal/tool_runtime/
runtime.rs

1use std::path::{Path, PathBuf};
2
3use async_trait::async_trait;
4use microclaw_core::llm_types::ToolDefinition;
5use serde_json::json;
6
7use crate::internal::tool_runtime::sandbox::SandboxMode;
8use crate::internal::tool_runtime::types::WorkingDirIsolation;
9
10pub struct ToolResult {
11    pub content: String,
12    pub is_error: bool,
13    pub status_code: Option<i32>,
14    pub bytes: usize,
15    pub duration_ms: Option<u128>,
16    pub error_type: Option<String>,
17    pub metadata: Option<serde_json::Value>,
18}
19
20impl ToolResult {
21    pub fn success(content: String) -> Self {
22        let bytes = content.len();
23        ToolResult {
24            content,
25            is_error: false,
26            status_code: Some(0),
27            bytes,
28            duration_ms: None,
29            error_type: None,
30            metadata: None,
31        }
32    }
33
34    pub fn error(content: String) -> Self {
35        let bytes = content.len();
36        ToolResult {
37            content,
38            is_error: true,
39            status_code: Some(1),
40            bytes,
41            duration_ms: None,
42            error_type: Some("tool_error".to_string()),
43            metadata: None,
44        }
45    }
46
47    pub fn with_status_code(mut self, status_code: i32) -> Self {
48        self.status_code = Some(status_code);
49        self
50    }
51
52    pub fn with_error_type(mut self, error_type: impl Into<String>) -> Self {
53        self.error_type = Some(error_type.into());
54        self
55    }
56
57    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
58        self.metadata = Some(metadata);
59        self
60    }
61}
62
63/// Declaration order is the risk ordering (Low < Medium < High) — the derived
64/// `Ord` is relied on by the tool-policy `max_risk` comparison.
65#[derive(
66    Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
67)]
68#[serde(rename_all = "lowercase")]
69pub enum ToolRisk {
70    Low,
71    Medium,
72    High,
73}
74
75impl ToolRisk {
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            ToolRisk::Low => "low",
79            ToolRisk::Medium => "medium",
80            ToolRisk::High => "high",
81        }
82    }
83}
84
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum ToolExecutionPolicy {
87    HostOnly,
88    SandboxOnly,
89    Dual,
90}
91
92impl ToolExecutionPolicy {
93    pub fn as_str(&self) -> &'static str {
94        match self {
95            ToolExecutionPolicy::HostOnly => "host-only",
96            ToolExecutionPolicy::SandboxOnly => "sandbox-only",
97            ToolExecutionPolicy::Dual => "dual",
98        }
99    }
100}
101
102pub fn tool_risk(name: &str) -> ToolRisk {
103    match name {
104        "bash" => ToolRisk::High,
105        "write_file"
106        | "edit_file"
107        | "write_memory"
108        | "a2a_send"
109        | "send_message"
110        | "sync_skills"
111        | "schedule_task"
112        | "pause_scheduled_task"
113        | "resume_scheduled_task"
114        | "cancel_scheduled_task"
115        | "replay_scheduled_task_dlq"
116        | "structured_memory_delete"
117        | "structured_memory_update" => ToolRisk::Medium,
118        // MCP tools are external code with unknown side effects; rating them
119        // Low would let them sail under a `max_risk` policy that blocks
120        // built-in write tools. Medium matches their SideEffect concurrency
121        // default.
122        name if name.starts_with("mcp_") => ToolRisk::Medium,
123        _ => ToolRisk::Low,
124    }
125}
126
127/// Classification for parallel tool execution within a single agent turn.
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ToolConcurrencyClass {
130    /// Safe to run concurrently with any other ReadOnly tool.
131    ReadOnly,
132    /// Has side effects — must run sequentially with respect to other SideEffect/Exclusive tools.
133    SideEffect,
134    /// Must run completely alone in its own execution wave (e.g., bash, activate_skill).
135    Exclusive,
136}
137
138/// Returns the concurrency class for a built-in tool.
139/// MCP tools (prefixed with `mcp_`) default to `SideEffect`.
140/// Callers may override via `tool_concurrency_overrides` config.
141pub fn tool_concurrency_class(name: &str) -> ToolConcurrencyClass {
142    match name {
143        // Read-only tools: safe to parallelize
144        "read_file"
145        | "glob"
146        | "grep"
147        | "web_fetch"
148        | "web_search"
149        | "deep_research"
150        | "get_current_time"
151        | "compare_time"
152        | "calculate"
153        | "read_memory"
154        | "structured_memory_search"
155        | "todo_read"
156        | "export_chat"
157        | "a2a_list_peers"
158        | "list_scheduled_tasks"
159        | "get_scheduled_task_history"
160        | "list_scheduled_task_dlq"
161        | "subagents_list"
162        | "subagents_info"
163        | "subagents_focused"
164        | "subagents_log"
165        | "browser"
166        | "fetch_artifact" => ToolConcurrencyClass::ReadOnly,
167        // Exclusive tools: must run alone
168        "bash" | "activate_skill" | "sessions_spawn" => ToolConcurrencyClass::Exclusive,
169        // MCP tools: default to SideEffect (unknown external effects)
170        _ if name.starts_with("mcp_") => ToolConcurrencyClass::SideEffect,
171        // All other tools with side effects
172        "write_file"
173        | "edit_file"
174        | "write_memory"
175        | "send_message"
176        | "a2a_send"
177        | "schedule_task"
178        | "pause_scheduled_task"
179        | "resume_scheduled_task"
180        | "cancel_scheduled_task"
181        | "replay_scheduled_task_dlq"
182        | "structured_memory_update"
183        | "structured_memory_delete"
184        | "todo_write"
185        | "sync_skills"
186        | "subagents_send"
187        | "subagents_focus"
188        | "subagents_unfocus"
189        | "subagents_kill"
190        | "subagents_retry_announces"
191        | "subagents_orchestrate" => ToolConcurrencyClass::SideEffect,
192        // Unknown tools: conservative default
193        _ => ToolConcurrencyClass::SideEffect,
194    }
195}
196
197/// Parse a concurrency class from a config string.
198pub fn parse_concurrency_class(s: &str) -> Option<ToolConcurrencyClass> {
199    match s.to_ascii_lowercase().as_str() {
200        "read_only" | "readonly" => Some(ToolConcurrencyClass::ReadOnly),
201        "side_effect" | "sideeffect" => Some(ToolConcurrencyClass::SideEffect),
202        "exclusive" => Some(ToolConcurrencyClass::Exclusive),
203        _ => None,
204    }
205}
206
207pub fn tool_execution_policy(name: &str) -> ToolExecutionPolicy {
208    match name {
209        "bash" => ToolExecutionPolicy::Dual,
210        "write_file" | "edit_file" => ToolExecutionPolicy::HostOnly,
211        _ => ToolExecutionPolicy::HostOnly,
212    }
213}
214
215pub fn validate_execution_policy(
216    name: &str,
217    sandbox_mode: SandboxMode,
218    sandbox_runtime_available: bool,
219) -> Result<(), String> {
220    let policy = tool_execution_policy(name);
221    match policy {
222        ToolExecutionPolicy::HostOnly => Ok(()),
223        ToolExecutionPolicy::SandboxOnly => {
224            if sandbox_mode == SandboxMode::All && sandbox_runtime_available {
225                Ok(())
226            } else {
227                Err(format!(
228                    "Tool '{name}' requires sandbox runtime (policy: {}). Enable sandbox and ensure docker runtime is available.",
229                    policy.as_str()
230                ))
231            }
232        }
233        ToolExecutionPolicy::Dual => {
234            if sandbox_mode == SandboxMode::All && !sandbox_runtime_available {
235                // Allowed by current fallback behavior, but explicit for observability.
236                tracing::warn!(
237                    tool = name,
238                    policy = policy.as_str(),
239                    "sandbox configured but runtime unavailable; falling back to host execution"
240                );
241            }
242            Ok(())
243        }
244    }
245}
246
247#[derive(Clone, Debug)]
248pub struct ToolAuthContext {
249    pub caller_channel: String,
250    pub caller_chat_id: i64,
251    /// Stable authorization principal (`main`, `scheduler`, or
252    /// `subagent:<run-id>`). Tool grants can match this value or a trailing
253    /// wildcard such as `subagent:*`.
254    pub principal: String,
255    pub control_chat_ids: Vec<i64>,
256    pub env_files: Vec<String>,
257}
258
259impl ToolAuthContext {
260    pub fn is_control_chat(&self) -> bool {
261        self.control_chat_ids.contains(&self.caller_chat_id)
262    }
263
264    pub fn can_access_chat(&self, target_chat_id: i64) -> bool {
265        self.is_control_chat() || self.caller_chat_id == target_chat_id
266    }
267}
268
269const AUTH_CONTEXT_KEY: &str = "__microclaw_auth";
270
271pub fn auth_context_from_input(input: &serde_json::Value) -> Option<ToolAuthContext> {
272    let ctx = input.get(AUTH_CONTEXT_KEY)?;
273    let caller_channel = ctx
274        .get("caller_channel")
275        .and_then(|v| v.as_str())
276        .unwrap_or("telegram")
277        .to_string();
278    let caller_chat_id = ctx.get("caller_chat_id")?.as_i64()?;
279    let control_chat_ids = ctx
280        .get("control_chat_ids")
281        .and_then(|v| v.as_array())
282        .map(|arr| arr.iter().filter_map(|x| x.as_i64()).collect())
283        .unwrap_or_default();
284    let principal = ctx
285        .get("principal")
286        .and_then(|v| v.as_str())
287        .unwrap_or("main")
288        .to_string();
289    let env_files = ctx
290        .get("env_files")
291        .and_then(|v| v.as_array())
292        .map(|arr| {
293            arr.iter()
294                .filter_map(|x| x.as_str().map(str::to_string))
295                .collect()
296        })
297        .unwrap_or_default();
298    Some(ToolAuthContext {
299        caller_channel,
300        caller_chat_id,
301        principal,
302        control_chat_ids,
303        env_files,
304    })
305}
306
307pub fn authorize_chat_access(input: &serde_json::Value, target_chat_id: i64) -> Result<(), String> {
308    if let Some(auth) = auth_context_from_input(input) {
309        if !auth.can_access_chat(target_chat_id) {
310            return Err(format!(
311                "Permission denied: chat {} cannot operate on chat {}",
312                auth.caller_chat_id, target_chat_id
313            ));
314        }
315    }
316    Ok(())
317}
318
319pub fn inject_auth_context(input: serde_json::Value, auth: &ToolAuthContext) -> serde_json::Value {
320    let mut obj = match input {
321        serde_json::Value::Object(map) => map,
322        _ => serde_json::Map::new(),
323    };
324    let mut auth_val = json!({
325        "caller_channel": auth.caller_channel,
326        "caller_chat_id": auth.caller_chat_id,
327        "principal": auth.principal,
328        "control_chat_ids": auth.control_chat_ids,
329    });
330    if !auth.env_files.is_empty() {
331        auth_val["env_files"] = json!(auth.env_files);
332    }
333    obj.insert(AUTH_CONTEXT_KEY.to_string(), auth_val);
334    serde_json::Value::Object(obj)
335}
336
337#[async_trait]
338pub trait Tool: Send + Sync {
339    fn name(&self) -> &str;
340    fn definition(&self) -> ToolDefinition;
341    async fn execute(&self, input: serde_json::Value) -> ToolResult;
342}
343
344pub fn resolve_tool_path(working_dir: &Path, path: &str) -> PathBuf {
345    let candidate = PathBuf::from(path);
346    if candidate.is_absolute() {
347        candidate
348    } else {
349        working_dir.join(candidate)
350    }
351}
352
353fn sanitize_channel_segment(channel: &str) -> String {
354    let mut out = String::with_capacity(channel.len());
355    for c in channel.chars() {
356        if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
357            out.push(c.to_ascii_lowercase());
358        } else {
359            out.push('_');
360        }
361    }
362    if out.is_empty() {
363        "unknown".to_string()
364    } else {
365        out
366    }
367}
368
369pub fn chat_working_dir(base_working_dir: &Path, channel: &str, chat_id: i64) -> PathBuf {
370    let chat_segment = if chat_id < 0 {
371        format!("neg{}", chat_id.unsigned_abs())
372    } else {
373        chat_id.to_string()
374    };
375    base_working_dir
376        .join("chat")
377        .join(sanitize_channel_segment(channel))
378        .join(chat_segment)
379}
380
381pub fn working_dir_for_context(
382    base_working_dir: &Path,
383    isolation: WorkingDirIsolation,
384    channel: &str,
385    chat_id: i64,
386) -> PathBuf {
387    match isolation {
388        WorkingDirIsolation::Direct => base_working_dir.to_path_buf(),
389        WorkingDirIsolation::Shared => base_working_dir.join("shared"),
390        WorkingDirIsolation::Chat => chat_working_dir(base_working_dir, channel, chat_id),
391    }
392}
393
394pub fn resolve_tool_working_dir(
395    base_working_dir: &Path,
396    isolation: WorkingDirIsolation,
397    input: &serde_json::Value,
398) -> PathBuf {
399    let resolved = match isolation {
400        WorkingDirIsolation::Direct => base_working_dir.to_path_buf(),
401        WorkingDirIsolation::Shared => base_working_dir.join("shared"),
402        WorkingDirIsolation::Chat => auth_context_from_input(input)
403            .map(|auth| {
404                chat_working_dir(base_working_dir, &auth.caller_channel, auth.caller_chat_id)
405            })
406            .unwrap_or_else(|| base_working_dir.join("shared")),
407    };
408    let _ = std::fs::create_dir_all(&resolved);
409    resolved
410}
411
412fn requires_high_risk_approval(risk: ToolRisk, auth: &ToolAuthContext) -> bool {
413    risk == ToolRisk::High
414        && (auth.caller_channel == "web" || auth.caller_channel == "work" || auth.is_control_chat())
415}
416
417/// Input marker injected after explicit operator approval so a high-risk
418/// tool (or an in-tool gate like bash dangerous patterns / sandboxed A2A
419/// peers) executes on retry. Shared across crates so a rename can't silently
420/// desync the producers and consumers.
421pub const HIGH_RISK_APPROVED_KEY: &str = "__microclaw_high_risk_approved";
422
423pub fn require_high_risk_approval(
424    name: &str,
425    auth: &ToolAuthContext,
426    input: &serde_json::Value,
427) -> Option<ToolResult> {
428    require_high_risk_approval_with_risk(name, tool_risk(name), auth, input)
429}
430
431/// Like [`require_high_risk_approval`] but with the tool's effective risk
432/// supplied by the caller — used when a registration-time override (e.g. an
433/// MCP server trust tier) replaces the name-derived risk.
434pub fn require_high_risk_approval_with_risk(
435    name: &str,
436    risk: ToolRisk,
437    auth: &ToolAuthContext,
438    input: &serde_json::Value,
439) -> Option<ToolResult> {
440    if !requires_high_risk_approval(risk, auth) {
441        return None;
442    }
443
444    let approved = input
445        .get(HIGH_RISK_APPROVED_KEY)
446        .and_then(|v| v.as_bool())
447        .unwrap_or(false);
448    if approved {
449        tracing::warn!(
450            tool = name,
451            channel = auth.caller_channel.as_str(),
452            chat_id = auth.caller_chat_id,
453            "Executing high-risk tool with explicit approval marker"
454        );
455        None
456    } else {
457        Some(
458            ToolResult::error(format!(
459                "Approval required for high-risk tool '{name}' (risk: {}). Add `{HIGH_RISK_APPROVED_KEY}: true` only after explicit operator approval.",
460                risk.as_str(),
461            ))
462            .with_error_type("approval_required"),
463        )
464    }
465}
466
467pub fn schema_object(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
468    json!({
469        "type": "object",
470        "properties": properties,
471        "required": required,
472    })
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn test_tool_execution_policy_levels() {
481        assert_eq!(tool_execution_policy("bash"), ToolExecutionPolicy::Dual);
482        assert_eq!(
483            tool_execution_policy("write_file"),
484            ToolExecutionPolicy::HostOnly
485        );
486        assert_eq!(
487            tool_execution_policy("read_file"),
488            ToolExecutionPolicy::HostOnly
489        );
490    }
491
492    #[test]
493    fn test_validate_execution_policy_dual_allows_fallback() {
494        let ok = validate_execution_policy("bash", SandboxMode::All, false);
495        assert!(ok.is_ok());
496    }
497
498    #[test]
499    fn work_requires_high_risk_approval_but_cli_headless_remains_noninteractive() {
500        let auth = |channel: &str| ToolAuthContext {
501            caller_channel: channel.into(),
502            caller_chat_id: 42,
503            principal: "main".into(),
504            control_chat_ids: Vec::new(),
505            env_files: Vec::new(),
506        };
507
508        assert!(require_high_risk_approval("bash", &auth("work"), &json!({})).is_some());
509        assert!(require_high_risk_approval("bash", &auth("headless"), &json!({})).is_none());
510    }
511}