microclaw_engine/internal/tool_runtime/
runtime.rs1use 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#[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 name if name.starts_with("mcp_") => ToolRisk::Medium,
123 _ => ToolRisk::Low,
124 }
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum ToolConcurrencyClass {
130 ReadOnly,
132 SideEffect,
134 Exclusive,
136}
137
138pub fn tool_concurrency_class(name: &str) -> ToolConcurrencyClass {
142 match name {
143 "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 "bash" | "activate_skill" | "sessions_spawn" => ToolConcurrencyClass::Exclusive,
169 _ if name.starts_with("mcp_") => ToolConcurrencyClass::SideEffect,
171 "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 _ => ToolConcurrencyClass::SideEffect,
194 }
195}
196
197pub 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 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 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
417pub 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
431pub 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}