1pub mod a2a;
2pub mod activate_skill;
3pub mod bash;
4pub mod browser;
5pub mod clarify;
6pub mod consult_specialist;
7pub mod deep_research;
8pub mod describe_image;
9pub mod edit_file;
10pub mod export_chat;
11pub mod fetch_artifact;
12pub mod fuzzy_match;
13pub mod generate_image;
14pub mod generate_podcast;
15pub mod glob;
16pub mod grep;
17pub mod insights;
18pub mod knowledge_graph;
19pub mod learning_tracks;
20pub mod mcp;
21pub mod memory;
22pub mod osv_check;
23pub mod read_file;
24#[cfg(feature = "document-tools")]
25pub mod render_pdf;
26pub mod report_progress;
27pub mod schedule;
28pub mod send_message;
29pub mod session_search;
30pub mod skill_manage;
31pub mod specialists;
32pub mod structured_memory;
33pub mod subagents;
34pub mod sync_skills;
35pub mod text_to_speech;
36pub mod time_math;
37pub mod todo;
38pub mod transcribe_audio;
39pub mod web_fetch;
40pub mod web_search;
41pub mod write_file;
42
43use std::sync::{Arc, OnceLock};
44use std::{path::PathBuf, time::Instant};
45
46pub const IDEMPOTENT_TOOLS: &[&str] = &[
52 "deep_research",
53 "describe_image",
54 "export_chat",
55 "fetch_artifact",
56 "glob",
57 "grep",
58 "insights",
59 "osv_check",
60 "read_file",
61 "session_search",
62 "time_math",
63 "transcribe_audio",
64 "web_fetch",
65 "web_search",
66];
67
68use crate::config::Config;
69use crate::internal::channels::channel_adapter::ChannelRegistry;
70use crate::internal::storage::db::Database;
71use crate::internal::tool_runtime::path_guard::{self, GovernanceGuard};
72pub use crate::internal::tool_runtime::runtime::{
73 auth_context_from_input, authorize_chat_access, resolve_tool_path, resolve_tool_working_dir,
74 schema_object, tool_execution_policy, tool_risk, validate_execution_policy, Tool,
75 ToolAuthContext, ToolResult, ToolRisk,
76};
77use crate::internal::tool_runtime::runtime::{
78 inject_auth_context, require_high_risk_approval_with_risk,
79};
80use crate::internal::tool_runtime::sandbox::{ExtraMount, SandboxMode, SandboxRouter};
81use crate::memory_backend::MemoryBackend;
82use microclaw_core::llm_types::ToolDefinition;
83
84pub struct ToolRegistry {
85 config: Config,
86 tools: Vec<Box<dyn Tool>>,
87 sandbox_mode: SandboxMode,
88 sandbox_runtime_available: bool,
89 cached_static_definitions: OnceLock<Vec<ToolDefinition>>,
90 audit_db: Option<Arc<Database>>,
93 risk_overrides: std::collections::HashMap<String, ToolRisk>,
97}
98
99impl ToolRegistry {
100 fn should_inject_default_chat_id(tool_name: &str) -> bool {
101 matches!(
102 tool_name,
103 "write_memory"
104 | "read_memory"
105 | "todo_read"
106 | "todo_write"
107 | "send_message"
108 | "sessions_spawn"
109 | "subagents_list"
110 | "subagents_info"
111 | "subagents_kill"
112 | "subagents_focus"
113 | "subagents_unfocus"
114 | "subagents_focused"
115 | "subagents_send"
116 | "subagents_orchestrate"
117 | "session_search"
118 | "learning_tracks"
119 )
120 }
121
122 fn inject_default_chat_id_if_missing(
123 tool_name: &str,
124 input: serde_json::Value,
125 auth: &ToolAuthContext,
126 ) -> serde_json::Value {
127 if !Self::should_inject_default_chat_id(tool_name) {
128 return input;
129 }
130 let mut obj = match input {
131 serde_json::Value::Object(map) => map,
132 _ => serde_json::Map::new(),
133 };
134 let missing_chat_id = obj.get("chat_id").and_then(|v| v.as_i64()).is_none();
135 if missing_chat_id {
136 obj.insert(
137 "chat_id".to_string(),
138 serde_json::Value::Number(auth.caller_chat_id.into()),
139 );
140 }
141 serde_json::Value::Object(obj)
142 }
143
144 pub fn new(
145 config: &Config,
146 channel_registry: Arc<ChannelRegistry>,
147 db: Arc<Database>,
148 memory_backend: Arc<MemoryBackend>,
149 ) -> Self {
150 let audit_db = Some(db.clone());
151 let working_dir = PathBuf::from(&config.working_dir);
152 if let Err(e) = std::fs::create_dir_all(&working_dir) {
153 tracing::warn!(
154 "Failed to create working_dir '{}': {}",
155 working_dir.display(),
156 e
157 );
158 }
159 let skills_data_dir = config.skills_data_dir();
160 let sandbox_router = Arc::new(SandboxRouter::new(
161 config.sandbox.clone(),
162 &working_dir,
163 Self::build_extra_mounts(&working_dir, &skills_data_dir),
164 ));
165 tracing::info!(
166 mode = ?sandbox_router.mode(),
167 backend = sandbox_router.backend_name(),
168 "Sandbox initialized"
169 );
170 let mut tools: Vec<Box<dyn Tool>> = vec![
171 Box::new(
172 bash::BashTool::new_with_isolation(
173 &config.working_dir,
174 config.working_dir_isolation,
175 )
176 .with_default_timeout_secs(config.tool_timeout_secs("bash", 120))
177 .with_sandbox_router(sandbox_router.clone())
178 .with_dangerous_patterns(&config.bash_dangerous_patterns)
179 .with_rtk(&config.rtk),
180 ),
181 Box::new(
182 browser::BrowserTool::new(&config.data_dir)
183 .with_default_timeout_secs(config.tool_timeout_secs("browser", 30)),
184 ),
185 Box::new(read_file::ReadFileTool::new_with_isolation(
186 &config.working_dir,
187 config.working_dir_isolation,
188 )),
189 Box::new(
190 write_file::WriteFileTool::new_with_isolation(
191 &config.working_dir,
192 config.working_dir_isolation,
193 )
194 .with_diff_max_lines(config.diff_max_lines)
195 .with_governance_guard(Self::governance_guard(config)),
196 ),
197 Box::new(
198 edit_file::EditFileTool::new_with_isolation(
199 &config.working_dir,
200 config.working_dir_isolation,
201 )
202 .with_diff_max_lines(config.diff_max_lines)
203 .with_governance_guard(Self::governance_guard(config)),
204 ),
205 Box::new(glob::GlobTool::new_with_isolation(
206 &config.working_dir,
207 config.working_dir_isolation,
208 )),
209 Box::new(grep::GrepTool::new_with_isolation(
210 &config.working_dir,
211 config.working_dir_isolation,
212 )),
213 Box::new(memory::ReadMemoryTool::new(&config.data_dir, db.clone())),
214 Box::new(memory::WriteMemoryTool::new(
215 &config.data_dir,
216 db.clone(),
217 memory_backend.clone(),
218 )),
219 Box::new(web_fetch::WebFetchTool::new(
220 config.tool_timeout_secs("web_fetch", 15),
221 config.web_fetch_validation,
222 config.web_fetch_url_validation.clone(),
223 )),
224 Box::new(
225 web_search::WebSearchTool::new(config.tool_timeout_secs("web_search", 15))
226 .with_provider(config.web_search.clone()),
227 ),
228 Box::new(deep_research::DeepResearchTool::new(
229 config.tool_timeout_secs("deep_research", 20),
230 config.web_search.clone(),
231 config.web_fetch_validation,
232 config.web_fetch_url_validation.clone(),
233 )),
234 Box::new(time_math::GetCurrentTimeTool::new(config.timezone.clone())),
235 Box::new(time_math::CompareTimeTool::new(config.timezone.clone())),
236 Box::new(time_math::CalculateTool::new()),
237 Box::new(send_message::SendMessageTool::new(
238 channel_registry.clone(),
239 db.clone(),
240 if config.bot_username.trim().is_empty() {
241 "bot".to_string()
242 } else {
243 config.bot_username.clone()
244 },
245 config.bot_username_overrides(),
246 )),
247 Box::new(a2a::A2AListPeersTool::new(config)),
248 Box::new(a2a::A2ASendTool::new(config)),
249 Box::new(schedule::ScheduleTaskTool::new(
250 channel_registry.clone(),
251 db.clone(),
252 config.timezone.clone(),
253 )),
254 Box::new(schedule::ListTasksTool::new(
255 channel_registry.clone(),
256 db.clone(),
257 )),
258 Box::new(schedule::PauseTaskTool::new(
259 channel_registry.clone(),
260 db.clone(),
261 )),
262 Box::new(schedule::ResumeTaskTool::new(
263 channel_registry.clone(),
264 db.clone(),
265 )),
266 Box::new(schedule::CancelTaskTool::new(
267 channel_registry.clone(),
268 db.clone(),
269 )),
270 Box::new(schedule::GetTaskHistoryTool::new(
271 channel_registry.clone(),
272 db.clone(),
273 )),
274 Box::new(schedule::ListTaskDlqTool::new(
275 channel_registry.clone(),
276 db.clone(),
277 )),
278 Box::new(schedule::ReplayTaskDlqTool::new(
279 channel_registry.clone(),
280 db.clone(),
281 )),
282 Box::new(learning_tracks::LearningTracksTool::new(
283 db.clone(),
284 config.timezone.clone(),
285 )),
286 Box::new(export_chat::ExportChatTool::new(
287 db.clone(),
288 &config.data_dir,
289 )),
290 Box::new(subagents::SessionsSpawnTool::new(
291 config,
292 db.clone(),
293 channel_registry.clone(),
294 )),
295 Box::new(subagents::SubagentsListTool::new(db.clone())),
296 Box::new(subagents::SubagentsInfoTool::new(db.clone())),
297 Box::new(subagents::SubagentsKillTool::new(config, db.clone())),
298 Box::new(subagents::SubagentsFocusTool::new(db.clone())),
299 Box::new(subagents::SubagentsUnfocusTool::new(db.clone())),
300 Box::new(subagents::SubagentsFocusedTool::new(db.clone())),
301 Box::new(subagents::SubagentsSendTool::new(
302 config,
303 db.clone(),
304 channel_registry.clone(),
305 )),
306 Box::new(subagents::SubagentsOrchestrateTool::new(
307 config,
308 db.clone(),
309 channel_registry.clone(),
310 )),
311 Box::new(subagents::SubagentsLogTool::new(db.clone())),
312 Box::new(subagents::SubagentsRetryAnnouncesTool::new(
313 config,
314 db.clone(),
315 channel_registry.clone(),
316 )),
317 Box::new(
318 activate_skill::ActivateSkillTool::new_with_runtime(
319 &skills_data_dir,
320 &config.data_dir,
321 )
322 .with_config_verification(config)
323 .with_db(db.clone()),
324 ),
325 Box::new(
326 skill_manage::SkillManageTool::new(
327 &skills_data_dir,
328 config.control_chat_ids.clone(),
329 )
330 .with_db(db.clone()),
331 ),
332 Box::new(sync_skills::SyncSkillsTool::new(&skills_data_dir)),
333 Box::new(todo::TodoReadTool::new(&config.data_dir)),
334 Box::new(todo::TodoWriteTool::new(&config.data_dir).with_db(db.clone())),
335 Box::new(structured_memory::StructuredMemorySearchTool::new(
336 db.clone(),
337 memory_backend.clone(),
338 )),
339 Box::new(structured_memory::StructuredMemoryDeleteTool::new(
340 db.clone(),
341 memory_backend.clone(),
342 )),
343 Box::new(structured_memory::StructuredMemoryUpdateTool::new(
344 db.clone(),
345 memory_backend.clone(),
346 )),
347 Box::new(knowledge_graph::KnowledgeGraphQueryTool::new(db.clone())),
348 Box::new(knowledge_graph::KnowledgeGraphAddTool::new(db.clone())),
349 Box::new(session_search::SessionSearchTool::new(db.clone())),
350 Box::new(
351 osv_check::OsvCheckTool::new(config.tool_timeout_secs("osv_check", 10))
352 .with_cache(db.clone()),
353 ),
354 Box::new(clarify::ClarifyTool::new(
355 channel_registry.clone(),
356 db.clone(),
357 if config.bot_username.trim().is_empty() {
358 "bot".to_string()
359 } else {
360 config.bot_username.clone()
361 },
362 config.bot_username_overrides(),
363 )),
364 Box::new(generate_image::GenerateImageTool::new(
365 config,
366 channel_registry.clone(),
367 db.clone(),
368 )),
369 Box::new(describe_image::DescribeImageTool::new(config)),
370 Box::new(text_to_speech::TextToSpeechTool::new(
371 config,
372 channel_registry.clone(),
373 db.clone(),
374 )),
375 Box::new(generate_podcast::GeneratePodcastTool::new(
376 config,
377 channel_registry.clone(),
378 db.clone(),
379 )),
380 Box::new(transcribe_audio::TranscribeAudioTool::new(config)),
381 Box::new(insights::InsightsTool::new(db.clone())),
382 Box::new(fetch_artifact::FetchArtifactTool::new(db.clone())),
383 ];
384
385 #[cfg(feature = "document-tools")]
386 tools.push(Box::new(render_pdf::RenderPdfTool::new(
387 config,
388 channel_registry.clone(),
389 db.clone(),
390 )));
391
392 if config.clawhub.agent_tools_enabled {
394 tools.push(Box::new(crate::clawhub::tools::ClawHubSearchTool::new(
395 config,
396 )));
397 tools.push(Box::new(crate::clawhub::tools::ClawHubInstallTool::new(
398 config,
399 )));
400 }
401
402 ToolRegistry {
403 config: config.clone(),
404 tools,
405 sandbox_mode: sandbox_router.mode(),
406 sandbox_runtime_available: sandbox_router.runtime_available(),
407 cached_static_definitions: OnceLock::new(),
408 audit_db,
409 risk_overrides: std::collections::HashMap::new(),
410 }
411 }
412
413 pub fn new_sub_agent(
416 config: &Config,
417 db: Arc<Database>,
418 channel_registry: Option<Arc<ChannelRegistry>>,
419 allow_session_tools: bool,
420 ) -> Self {
421 let audit_db = Some(db.clone());
422 let working_dir = PathBuf::from(&config.working_dir);
423 if let Err(e) = std::fs::create_dir_all(&working_dir) {
424 tracing::warn!(
425 "Failed to create working_dir '{}': {}",
426 working_dir.display(),
427 e
428 );
429 }
430 let skills_data_dir = config.skills_data_dir();
431 let sandbox_router = Arc::new(SandboxRouter::new(
432 config.sandbox.clone(),
433 &working_dir,
434 Self::build_extra_mounts(&working_dir, &skills_data_dir),
435 ));
436 let memory_backend = Arc::new(MemoryBackend::local_only(db.clone()));
437 let mut tools: Vec<Box<dyn Tool>> = vec![
438 Box::new(
439 bash::BashTool::new_with_isolation(
440 &config.working_dir,
441 config.working_dir_isolation,
442 )
443 .with_default_timeout_secs(config.tool_timeout_secs("bash", 120))
444 .with_sandbox_router(sandbox_router.clone())
445 .with_dangerous_patterns(&config.bash_dangerous_patterns)
446 .with_rtk(&config.rtk),
447 ),
448 Box::new(
449 browser::BrowserTool::new(&config.data_dir)
450 .with_default_timeout_secs(config.tool_timeout_secs("browser", 30)),
451 ),
452 Box::new(read_file::ReadFileTool::new_with_isolation(
453 &config.working_dir,
454 config.working_dir_isolation,
455 )),
456 Box::new(
457 write_file::WriteFileTool::new_with_isolation(
458 &config.working_dir,
459 config.working_dir_isolation,
460 )
461 .with_diff_max_lines(config.diff_max_lines)
462 .with_governance_guard(Self::governance_guard(config)),
463 ),
464 Box::new(
465 edit_file::EditFileTool::new_with_isolation(
466 &config.working_dir,
467 config.working_dir_isolation,
468 )
469 .with_diff_max_lines(config.diff_max_lines)
470 .with_governance_guard(Self::governance_guard(config)),
471 ),
472 Box::new(glob::GlobTool::new_with_isolation(
473 &config.working_dir,
474 config.working_dir_isolation,
475 )),
476 Box::new(grep::GrepTool::new_with_isolation(
477 &config.working_dir,
478 config.working_dir_isolation,
479 )),
480 Box::new(memory::ReadMemoryTool::new(&config.data_dir, db.clone())),
481 Box::new(web_fetch::WebFetchTool::new(
482 config.tool_timeout_secs("web_fetch", 15),
483 config.web_fetch_validation,
484 config.web_fetch_url_validation.clone(),
485 )),
486 Box::new(
487 web_search::WebSearchTool::new(config.tool_timeout_secs("web_search", 15))
488 .with_provider(config.web_search.clone()),
489 ),
490 Box::new(deep_research::DeepResearchTool::new(
491 config.tool_timeout_secs("deep_research", 20),
492 config.web_search.clone(),
493 config.web_fetch_validation,
494 config.web_fetch_url_validation.clone(),
495 )),
496 Box::new(time_math::GetCurrentTimeTool::new(config.timezone.clone())),
497 Box::new(time_math::CompareTimeTool::new(config.timezone.clone())),
498 Box::new(time_math::CalculateTool::new()),
499 Box::new(
500 activate_skill::ActivateSkillTool::new_with_runtime(
501 &skills_data_dir,
502 &config.data_dir,
503 )
504 .with_config_verification(config)
505 .with_db(db.clone()),
506 ),
507 Box::new(structured_memory::StructuredMemorySearchTool::new(
508 db.clone(),
509 memory_backend,
510 )),
511 Box::new(session_search::SessionSearchTool::new(db.clone())),
512 Box::new(
513 osv_check::OsvCheckTool::new(config.tool_timeout_secs("osv_check", 10))
514 .with_cache(db.clone()),
515 ),
516 Box::new(fetch_artifact::FetchArtifactTool::new(db.clone())),
517 Box::new(describe_image::DescribeImageTool::new(config)),
518 Box::new(consult_specialist::ConsultSpecialistTool::new(config)),
519 ];
520 if let Some(cr) = &channel_registry {
523 tools.push(Box::new(generate_image::GenerateImageTool::new(
524 config,
525 cr.clone(),
526 db.clone(),
527 )));
528 tools.push(Box::new(report_progress::ReportProgressTool::new(
529 config,
530 cr.clone(),
531 db.clone(),
532 )));
533 }
534 if allow_session_tools {
535 if let Some(channel_registry) = channel_registry {
536 tools.push(Box::new(subagents::SessionsSpawnTool::new(
537 config,
538 db.clone(),
539 channel_registry.clone(),
540 )));
541 tools.push(Box::new(subagents::SubagentsListTool::new(db.clone())));
542 tools.push(Box::new(subagents::SubagentsInfoTool::new(db.clone())));
543 tools.push(Box::new(subagents::SubagentsKillTool::new(
544 config,
545 db.clone(),
546 )));
547 tools.push(Box::new(subagents::SubagentsOrchestrateTool::new(
548 config,
549 db.clone(),
550 channel_registry.clone(),
551 )));
552 tools.push(Box::new(subagents::SubagentsLogTool::new(db.clone())));
553 }
554 }
555 ToolRegistry {
556 config: config.clone(),
557 tools,
558 sandbox_mode: sandbox_router.mode(),
559 sandbox_runtime_available: sandbox_router.runtime_available(),
560 cached_static_definitions: OnceLock::new(),
561 audit_db,
562 risk_overrides: std::collections::HashMap::new(),
563 }
564 }
565
566 fn governance_guard(config: &Config) -> GovernanceGuard {
569 if config.allow_governance_file_writes {
570 return GovernanceGuard::default();
571 }
572 let data_root = config.data_root_dir();
573 let souls_dir = PathBuf::from(config.souls_data_dir());
574 let mut guard = GovernanceGuard::default()
575 .protect_data_root(&data_root)
576 .protect_data_root(config.runtime_data_dir())
577 .exempt(&config.working_dir)
578 .exempt(config.skills_data_dir())
579 .protect_dir(&souls_dir)
580 .protect_dir(match &config.context_dir {
581 Some(dir) => PathBuf::from(shellexpand::tilde(dir).into_owned()),
582 None => data_root.join("context"),
583 });
584 if data_root.file_name().and_then(|n| n.to_str()) == Some("runtime") {
585 if let Some(parent) = data_root.parent() {
586 guard = guard.protect_data_root(parent);
587 }
588 }
589 if let Ok(Some(config_path)) = Config::resolve_config_path() {
590 guard = guard.protect_file(config_path);
591 }
592 if let Some(allowlist) = path_guard::path_allowlist_file() {
593 guard = guard.protect_file(allowlist);
594 }
595 guard = guard.protect_file("SOUL.md");
597 let mut soul_paths: Vec<String> = config.soul_path.iter().cloned().collect();
598 for (name, value) in &config.channels {
599 soul_paths.extend(config.soul_path_for_channel(name));
600 if let Some(accounts) = value.get("accounts").and_then(|v| v.as_mapping()) {
601 for account in accounts.keys().filter_map(|k| k.as_str()) {
602 soul_paths.extend(config.soul_path_for_channel(&format!("{name}.{account}")));
603 }
604 }
605 }
606 for soul in soul_paths {
607 let configured = PathBuf::from(shellexpand::tilde(&soul).into_owned());
608 if configured.is_relative() {
609 guard = guard
610 .protect_file(data_root.join(&configured))
611 .protect_file(souls_dir.join(&configured));
612 }
613 guard = guard.protect_file(configured);
614 }
615 guard
616 }
617
618 fn build_extra_mounts(working_dir: &PathBuf, skills_data_dir: &str) -> Vec<ExtraMount> {
619 let skills_path = PathBuf::from(skills_data_dir);
620 let canonical_skills = std::fs::canonicalize(&skills_path).unwrap_or(skills_path.clone());
621 let canonical_working =
622 std::fs::canonicalize(working_dir).unwrap_or_else(|_| working_dir.clone());
623 let mut mounts = Vec::new();
624 if canonical_skills.exists() && canonical_skills != canonical_working {
625 mounts.push(ExtraMount {
626 host_path: canonical_skills,
627 read_only: true,
628 });
629 }
630 mounts
631 }
632
633 pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
634 self.cached_static_definitions = OnceLock::new();
636 self.tools.push(tool);
637 }
638
639 pub fn add_tool_with_risk(&mut self, tool: Box<dyn Tool>, risk: ToolRisk) {
643 self.risk_overrides.insert(tool.name().to_string(), risk);
644 self.add_tool(tool);
645 }
646
647 pub fn register_mcp_tools(&mut self, mcp_manager: &crate::mcp::McpManager) {
651 for (server, tool_info) in mcp_manager.all_tools() {
652 let risk = match server.trust() {
653 crate::mcp::McpTrust::Trusted => ToolRisk::Low,
654 crate::mcp::McpTrust::Limited => ToolRisk::Medium,
655 crate::mcp::McpTrust::Sandboxed => ToolRisk::High,
656 };
657 self.add_tool_with_risk(
658 Box::new(crate::tools::mcp::McpTool::new(server, tool_info)),
659 risk,
660 );
661 }
662 }
663
664 pub fn effective_tool_risk(&self, name: &str) -> ToolRisk {
667 self.risk_overrides
668 .get(name)
669 .copied()
670 .unwrap_or_else(|| tool_risk(name))
671 }
672
673 pub fn definitions(&self) -> Vec<ToolDefinition> {
674 let static_defs = self
675 .cached_static_definitions
676 .get_or_init(|| self.tools.iter().map(|t| t.definition()).collect())
677 .clone();
678 let mut out = static_defs;
679 let mut existing: std::collections::HashSet<String> =
680 out.iter().map(|d| d.name.to_ascii_lowercase()).collect();
681 for plugin_def in crate::plugins::dynamic_plugin_tool_definitions(&self.config) {
682 let normalized = plugin_def.name.to_ascii_lowercase();
683 if existing.insert(normalized) {
684 out.push(plugin_def);
685 }
686 }
687 out
688 }
689
690 pub async fn execute(&self, name: &str, input: serde_json::Value) -> ToolResult {
691 for tool in &self.tools {
692 if tool.name() == name {
693 let started = Instant::now();
694 let mut result = tool.execute(input).await;
695 result.duration_ms = Some(started.elapsed().as_millis());
696 result.bytes = result.content.len();
697 if result.is_error && result.error_type.is_none() {
698 result.error_type = Some("tool_error".to_string());
699 }
700 if result.status_code.is_none() {
701 result.status_code = Some(if result.is_error { 1 } else { 0 });
702 }
703 return result;
704 }
705 }
706 ToolResult::error(format!("Unknown tool: {name}")).with_error_type("unknown_tool")
707 }
708
709 pub async fn execute_with_auth(
710 &self,
711 name: &str,
712 input: serde_json::Value,
713 auth: &ToolAuthContext,
714 ) -> ToolResult {
715 if auth.caller_channel == "work"
716 && auth.principal.starts_with("subagent:")
717 && !matches!(
718 name,
719 "web_search"
720 | "web_fetch"
721 | "deep_research"
722 | "read_memory"
723 | "structured_memory_search"
724 | "session_search"
725 | "read_file"
726 | "grep"
727 | "glob"
728 | "time_math"
729 | "get_current_time"
730 | "compare_time"
731 | "calculate"
732 | "osv_check"
733 | "fetch_artifact"
734 | "describe_image"
735 | "consult_specialist"
736 | "report_progress"
737 | "subagents_list"
738 | "subagents_info"
739 )
740 {
741 return ToolResult::error(format!(
742 "Tool `{name}` is not available to Work Subagents; Workspace writes are owned by the Main Agent"
743 ))
744 .with_error_type("work_subagent_read_only");
745 }
746 if auth.principal == "learning_foundry"
747 && !matches!(
748 name,
749 "web_search"
750 | "web_fetch"
751 | "deep_research"
752 | "read_memory"
753 | "session_search"
754 | "read_file"
755 | "grep"
756 | "glob"
757 | "time_math"
758 | "get_current_time"
759 | "compare_time"
760 | "calculate"
761 )
762 {
763 return ToolResult::error(format!(
764 "Tool `{name}` is not available to the read-only Learning Foundry principal"
765 ))
766 .with_error_type("learning_foundry_read_only");
767 }
768 let effective_risk = self.effective_tool_risk(name);
774 match crate::tool_guardrails::evaluate_tool_policy_for_auth_with_risk(
775 &self.config.tool_policy,
776 name,
777 auth,
778 effective_risk,
779 ) {
780 crate::tool_guardrails::PolicyDecision::Allow => {}
781 decision => {
782 let (action, status, block_reason) = match decision {
783 crate::tool_guardrails::PolicyDecision::Warn(reason) => {
784 ("warn", "allowed", Err(reason))
785 }
786 crate::tool_guardrails::PolicyDecision::Block(reason) => {
787 ("block", "blocked", Ok(reason))
788 }
789 crate::tool_guardrails::PolicyDecision::Allow => unreachable!(),
790 };
791 let reason = match &block_reason {
792 Ok(r) | Err(r) => r.clone(),
793 };
794 tracing::warn!(
795 tool = name,
796 chat_id = auth.caller_chat_id,
797 principal = %auth.principal,
798 action,
799 %reason,
800 "Tool policy violation"
801 );
802 if let Some(db) = self.audit_db.clone() {
803 let tool = name.to_string();
804 let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
805 let detail = reason.clone();
806 tokio::spawn(async move {
807 let _ = crate::internal::storage::db::call_blocking(db, move |db| {
808 db.log_audit_event(
809 "tool_policy",
810 &actor,
811 action,
812 Some(&tool),
813 status,
814 Some(&detail),
815 )
816 })
817 .await;
818 });
819 }
820 if block_reason.is_ok() {
821 return ToolResult::error(format!(
822 "Tool call blocked by policy: {reason}. Do not retry this tool; \
823 use a permitted tool or explain the limitation to the user."
824 ))
825 .with_error_type("tool_policy_blocked");
826 }
827 }
828 }
829 for (url, decision) in crate::internal::tool_runtime::egress::evaluate_tool_input(
830 &self.config.egress_policy,
831 &input,
832 ) {
833 let (action, status, reason, blocked) = match decision {
834 crate::internal::tool_runtime::egress::EgressDecision::Allow => continue,
835 crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
836 ("warn", "allowed", reason, false)
837 }
838 crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
839 ("block", "blocked", reason, true)
840 }
841 };
842 tracing::warn!(
843 tool = name,
844 chat_id = auth.caller_chat_id,
845 principal = %auth.principal,
846 %url,
847 action,
848 %reason,
849 "Egress policy violation"
850 );
851 if let Some(db) = self.audit_db.clone() {
852 let tool = name.to_string();
853 let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
854 let detail = format!("{reason}; url={url}");
855 tokio::spawn(async move {
856 let _ = crate::internal::storage::db::call_blocking(db, move |db| {
857 db.log_audit_event(
858 "egress_policy",
859 &actor,
860 action,
861 Some(&tool),
862 status,
863 Some(&detail),
864 )
865 })
866 .await;
867 });
868 }
869 if blocked {
870 return ToolResult::error(format!(
871 "Tool call blocked by egress policy: {reason}. Do not retry this destination."
872 ))
873 .with_error_type("egress_policy_blocked");
874 }
875 }
876 if let Err(msg) =
877 validate_execution_policy(name, self.sandbox_mode, self.sandbox_runtime_available)
878 {
879 return ToolResult::error(msg).with_error_type("execution_policy_blocked");
880 }
881 let mut input = input;
882 if self.config.high_risk_tool_user_confirmation_required {
883 let gateable = effective_risk == ToolRisk::High || name == "a2a_send";
889 if gateable {
890 let standing = if let Some(db) = self.audit_db.clone() {
897 let key = format!("approved_tool:{}:{}", auth.caller_chat_id, name);
898 crate::internal::storage::db::call_blocking(db, move |db| {
899 db.get_runtime_meta(&key)
900 })
901 .await
902 .ok()
903 .flatten()
904 .is_some()
905 } else {
906 false
907 };
908 if standing {
909 tracing::info!(
910 tool = name,
911 chat_id = auth.caller_chat_id,
912 "High-risk tool allowed by standing per-chat approval"
913 );
914 if let serde_json::Value::Object(map) = &mut input {
915 map.insert(
916 "__microclaw_high_risk_approved".to_string(),
917 serde_json::Value::Bool(true),
918 );
919 }
920 } else if let Some(blocked) =
921 require_high_risk_approval_with_risk(name, effective_risk, auth, &input)
922 {
923 return blocked;
924 }
925 }
926 }
927
928 tracing::debug!(
929 tool = name,
930 risk = effective_risk.as_str(),
931 execution_policy = tool_execution_policy(name).as_str(),
932 sandbox_mode = ?self.sandbox_mode,
933 sandbox_runtime_available = self.sandbox_runtime_available,
934 "tool execution policy evaluated"
935 );
936 let input = Self::inject_default_chat_id_if_missing(name, input, auth);
937 let input = inject_auth_context(input, auth);
938 let result = self.execute(name, input.clone()).await;
939 if result.error_type.as_deref() == Some("unknown_tool") {
940 if let Some(dynamic) =
941 crate::plugins::execute_dynamic_plugin_tool(&self.config, name, input).await
942 {
943 return dynamic;
944 }
945 }
946 result
947 }
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use crate::config::WorkingDirIsolation;
954 use async_trait::async_trait;
955 use serde_json::json;
956
957 #[test]
958 fn test_tool_result_success() {
959 let r = ToolResult::success("ok".into());
960 assert_eq!(r.content, "ok");
961 assert!(!r.is_error);
962 }
963
964 #[test]
965 fn test_tool_result_error() {
966 let r = ToolResult::error("fail".into());
967 assert_eq!(r.content, "fail");
968 assert!(r.is_error);
969 }
970
971 #[test]
972 fn test_schema_object() {
973 let schema = schema_object(
974 json!({
975 "name": {"type": "string"},
976 "age": {"type": "integer"}
977 }),
978 &["name"],
979 );
980 assert_eq!(schema["type"], "object");
981 assert!(schema["properties"]["name"].is_object());
982 assert!(schema["properties"]["age"].is_object());
983 let required = schema["required"].as_array().unwrap();
984 assert_eq!(required.len(), 1);
985 assert_eq!(required[0], "name");
986 }
987
988 #[test]
989 fn test_schema_object_empty_required() {
990 let schema = schema_object(json!({}), &[]);
991 let required = schema["required"].as_array().unwrap();
992 assert!(required.is_empty());
993 }
994
995 #[test]
996 fn test_auth_context_from_input() {
997 let input = json!({
998 "__microclaw_auth": {
999 "caller_channel": "telegram",
1000 "caller_chat_id": 123,
1001 "control_chat_ids": [123, 999]
1002 }
1003 });
1004 let auth = auth_context_from_input(&input).unwrap();
1005 assert_eq!(auth.caller_channel, "telegram");
1006 assert_eq!(auth.caller_chat_id, 123);
1007 assert!(auth.is_control_chat());
1008 assert!(auth.can_access_chat(456));
1009 }
1010
1011 #[test]
1012 fn test_authorize_chat_access_denied() {
1013 let input = json!({
1014 "__microclaw_auth": {
1015 "caller_channel": "telegram",
1016 "caller_chat_id": 100,
1017 "control_chat_ids": []
1018 }
1019 });
1020 let err = authorize_chat_access(&input, 200).unwrap_err();
1021 assert!(err.contains("Permission denied"));
1022 }
1023
1024 #[test]
1025 fn test_resolve_tool_working_dir_shared() {
1026 let dir = resolve_tool_working_dir(
1027 std::path::Path::new("/tmp/work"),
1028 WorkingDirIsolation::Shared,
1029 &json!({
1030 "__microclaw_auth": {
1031 "caller_channel": "telegram",
1032 "caller_chat_id": 123,
1033 "control_chat_ids": []
1034 }
1035 }),
1036 );
1037 assert_eq!(dir, std::path::PathBuf::from("/tmp/work/shared"));
1038 }
1039
1040 #[test]
1041 fn test_resolve_tool_working_dir_direct() {
1042 let dir = resolve_tool_working_dir(
1043 std::path::Path::new("/tmp/work"),
1044 WorkingDirIsolation::Direct,
1045 &json!({
1046 "__microclaw_auth": {
1047 "caller_channel": "headless",
1048 "caller_chat_id": 123,
1049 "control_chat_ids": []
1050 }
1051 }),
1052 );
1053 assert_eq!(dir, std::path::PathBuf::from("/tmp/work"));
1054 }
1055
1056 #[test]
1057 fn test_resolve_tool_working_dir_chat() {
1058 let dir = resolve_tool_working_dir(
1059 std::path::Path::new("/tmp/work"),
1060 WorkingDirIsolation::Chat,
1061 &json!({
1062 "__microclaw_auth": {
1063 "caller_channel": "discord",
1064 "caller_chat_id": -100123,
1065 "control_chat_ids": []
1066 }
1067 }),
1068 );
1069 assert_eq!(
1070 dir,
1071 std::path::PathBuf::from("/tmp/work/chat/discord/neg100123")
1072 );
1073 }
1074
1075 struct DummyTool {
1076 tool_name: String,
1077 }
1078
1079 #[async_trait]
1080 impl Tool for DummyTool {
1081 fn name(&self) -> &str {
1082 &self.tool_name
1083 }
1084
1085 fn definition(&self) -> ToolDefinition {
1086 ToolDefinition {
1087 name: self.tool_name.clone(),
1088 description: "dummy".into(),
1089 input_schema: schema_object(json!({}), &[]),
1090 }
1091 }
1092
1093 async fn execute(&self, _input: serde_json::Value) -> ToolResult {
1094 ToolResult::success("ok".into())
1095 }
1096 }
1097
1098 struct CaptureInputTool {
1099 tool_name: String,
1100 }
1101
1102 #[async_trait]
1103 impl Tool for CaptureInputTool {
1104 fn name(&self) -> &str {
1105 &self.tool_name
1106 }
1107
1108 fn definition(&self) -> ToolDefinition {
1109 ToolDefinition {
1110 name: self.tool_name.clone(),
1111 description: "capture".into(),
1112 input_schema: schema_object(json!({}), &[]),
1113 }
1114 }
1115
1116 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1117 ToolResult::success(input.to_string())
1118 }
1119 }
1120
1121 #[test]
1122 fn test_tool_risk_levels() {
1123 assert_eq!(tool_risk("bash"), ToolRisk::High);
1124 assert_eq!(tool_risk("write_file"), ToolRisk::Medium);
1125 assert_eq!(tool_risk("pause_scheduled_task"), ToolRisk::Medium);
1126 assert_eq!(tool_risk("sync_skills"), ToolRisk::Medium);
1127 assert_eq!(tool_risk("read_file"), ToolRisk::Low);
1128 }
1129
1130 #[tokio::test]
1131 async fn test_tool_policy_blocks_at_choke_point() {
1132 let mut config = crate::config::Config::test_defaults();
1136 config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
1137 config.tool_policy.deny_tools = vec!["bash".to_string()];
1138 let registry = ToolRegistry {
1139 config,
1140 sandbox_mode: SandboxMode::Off,
1141 sandbox_runtime_available: false,
1142 cached_static_definitions: OnceLock::new(),
1143 audit_db: None,
1144 risk_overrides: std::collections::HashMap::new(),
1145 tools: vec![Box::new(DummyTool {
1146 tool_name: "bash".into(),
1147 })],
1148 };
1149 let auth = ToolAuthContext {
1150 caller_channel: "telegram".into(),
1151 caller_chat_id: 1,
1152 principal: "main".into(),
1153 control_chat_ids: vec![],
1154 env_files: vec![],
1155 };
1156 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1157 assert!(result.is_error);
1158 assert_eq!(result.error_type.as_deref(), Some("tool_policy_blocked"));
1159
1160 let mut warn_config = crate::config::Config::test_defaults();
1162 warn_config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Warn;
1163 warn_config.tool_policy.deny_tools = vec!["bash".to_string()];
1164 warn_config.high_risk_tool_user_confirmation_required = false;
1165 let registry = ToolRegistry {
1166 config: warn_config,
1167 sandbox_mode: SandboxMode::Off,
1168 sandbox_runtime_available: false,
1169 cached_static_definitions: OnceLock::new(),
1170 audit_db: None,
1171 risk_overrides: std::collections::HashMap::new(),
1172 tools: vec![Box::new(DummyTool {
1173 tool_name: "bash".into(),
1174 })],
1175 };
1176 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1177 assert!(
1178 !result.is_error,
1179 "warn mode must still execute: {}",
1180 result.content
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn learning_foundry_principal_is_read_only_at_choke_point() {
1186 let registry = ToolRegistry {
1187 config: crate::config::Config::test_defaults(),
1188 sandbox_mode: SandboxMode::Off,
1189 sandbox_runtime_available: false,
1190 cached_static_definitions: OnceLock::new(),
1191 audit_db: None,
1192 risk_overrides: std::collections::HashMap::new(),
1193 tools: vec![
1194 Box::new(DummyTool {
1195 tool_name: "write_file".into(),
1196 }),
1197 Box::new(DummyTool {
1198 tool_name: "web_search".into(),
1199 }),
1200 ],
1201 };
1202 let auth = ToolAuthContext {
1203 caller_channel: "learning_foundry".into(),
1204 caller_chat_id: -7,
1205 principal: "learning_foundry".into(),
1206 control_chat_ids: vec![],
1207 env_files: vec![],
1208 };
1209 let write = registry
1210 .execute_with_auth("write_file", json!({}), &auth)
1211 .await;
1212 assert!(write.is_error);
1213 assert_eq!(
1214 write.error_type.as_deref(),
1215 Some("learning_foundry_read_only")
1216 );
1217 let search = registry
1218 .execute_with_auth("web_search", json!({}), &auth)
1219 .await;
1220 assert!(!search.is_error);
1221 }
1222
1223 #[tokio::test]
1224 async fn work_subagent_is_read_only_but_main_agent_can_write() {
1225 let make_registry = || ToolRegistry {
1226 config: crate::config::Config::test_defaults(),
1227 sandbox_mode: SandboxMode::Off,
1228 sandbox_runtime_available: false,
1229 cached_static_definitions: OnceLock::new(),
1230 audit_db: None,
1231 risk_overrides: std::collections::HashMap::new(),
1232 tools: vec![
1233 Box::new(DummyTool {
1234 tool_name: "write_file".into(),
1235 }),
1236 Box::new(DummyTool {
1237 tool_name: "read_file".into(),
1238 }),
1239 ],
1240 };
1241 let mut auth = ToolAuthContext {
1242 caller_channel: "work".into(),
1243 caller_chat_id: 1,
1244 principal: "subagent:run-1".into(),
1245 control_chat_ids: vec![],
1246 env_files: vec![],
1247 };
1248
1249 let denied = make_registry()
1250 .execute_with_auth("write_file", json!({}), &auth)
1251 .await;
1252 assert!(denied.is_error);
1253 assert_eq!(
1254 denied.error_type.as_deref(),
1255 Some("work_subagent_read_only")
1256 );
1257 let read = make_registry()
1258 .execute_with_auth("read_file", json!({}), &auth)
1259 .await;
1260 assert!(!read.is_error);
1261
1262 auth.principal = "main".into();
1263 let write = make_registry()
1264 .execute_with_auth("write_file", json!({}), &auth)
1265 .await;
1266 assert!(!write.is_error);
1267 }
1268
1269 #[tokio::test]
1270 async fn test_high_risk_tool_requires_explicit_approval_on_web() {
1271 let registry = ToolRegistry {
1272 config: crate::config::Config::test_defaults(),
1273 sandbox_mode: SandboxMode::Off,
1274 sandbox_runtime_available: false,
1275 cached_static_definitions: OnceLock::new(),
1276 audit_db: None,
1277 risk_overrides: std::collections::HashMap::new(),
1278 tools: vec![Box::new(DummyTool {
1279 tool_name: "bash".into(),
1280 })],
1281 };
1282 let auth = ToolAuthContext {
1283 caller_channel: "web".into(),
1284 caller_chat_id: 1,
1285 principal: "main".into(),
1286 control_chat_ids: vec![],
1287 env_files: vec![],
1288 };
1289
1290 let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1291 assert!(first.is_error);
1292 assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1293
1294 let second = registry.execute_with_auth("bash", json!({}), &auth).await;
1295 assert!(second.is_error);
1296 assert_eq!(second.error_type.as_deref(), Some("approval_required"));
1297
1298 let approved = registry
1299 .execute_with_auth(
1300 "bash",
1301 json!({"__microclaw_high_risk_approved": true}),
1302 &auth,
1303 )
1304 .await;
1305 assert!(!approved.is_error);
1306 assert_eq!(approved.content, "ok");
1307 }
1308
1309 #[tokio::test]
1310 async fn test_high_risk_tool_requires_explicit_approval_on_control_chat() {
1311 let registry = ToolRegistry {
1312 config: crate::config::Config::test_defaults(),
1313 sandbox_mode: SandboxMode::Off,
1314 sandbox_runtime_available: false,
1315 cached_static_definitions: OnceLock::new(),
1316 audit_db: None,
1317 risk_overrides: std::collections::HashMap::new(),
1318 tools: vec![Box::new(DummyTool {
1319 tool_name: "bash".into(),
1320 })],
1321 };
1322 let auth = ToolAuthContext {
1323 caller_channel: "telegram".into(),
1324 caller_chat_id: 123,
1325 principal: "main".into(),
1326 control_chat_ids: vec![123],
1327 env_files: vec![],
1328 };
1329
1330 let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1331 assert!(first.is_error);
1332 assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1333
1334 let approved = registry
1335 .execute_with_auth(
1336 "bash",
1337 json!({"__microclaw_high_risk_approved": true}),
1338 &auth,
1339 )
1340 .await;
1341 assert!(!approved.is_error);
1342 assert_eq!(approved.content, "ok");
1343 }
1344
1345 #[tokio::test]
1346 async fn test_high_risk_tool_confirmation_flag_false_hard_disables_web_approval_gate() {
1347 let mut config = crate::config::Config::test_defaults();
1348 config.high_risk_tool_user_confirmation_required = false;
1349 let registry = ToolRegistry {
1350 config,
1351 sandbox_mode: SandboxMode::Off,
1352 sandbox_runtime_available: false,
1353 cached_static_definitions: OnceLock::new(),
1354 audit_db: None,
1355 risk_overrides: std::collections::HashMap::new(),
1356 tools: vec![Box::new(DummyTool {
1357 tool_name: "bash".into(),
1358 })],
1359 };
1360 let auth = ToolAuthContext {
1361 caller_channel: "web".into(),
1362 caller_chat_id: 1,
1363 principal: "main".into(),
1364 control_chat_ids: vec![],
1365 env_files: vec![],
1366 };
1367
1368 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1369 assert!(!result.is_error);
1370 assert_eq!(result.content, "ok");
1371 }
1372
1373 #[tokio::test]
1374 async fn test_high_risk_tool_confirmation_flag_false_hard_disables_control_chat_approval_gate()
1375 {
1376 let mut config = crate::config::Config::test_defaults();
1377 config.high_risk_tool_user_confirmation_required = false;
1378 let registry = ToolRegistry {
1379 config,
1380 sandbox_mode: SandboxMode::Off,
1381 sandbox_runtime_available: false,
1382 cached_static_definitions: OnceLock::new(),
1383 audit_db: None,
1384 risk_overrides: std::collections::HashMap::new(),
1385 tools: vec![Box::new(DummyTool {
1386 tool_name: "bash".into(),
1387 })],
1388 };
1389 let auth = ToolAuthContext {
1390 caller_channel: "telegram".into(),
1391 caller_chat_id: 123,
1392 principal: "main".into(),
1393 control_chat_ids: vec![123],
1394 env_files: vec![],
1395 };
1396
1397 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1398 assert!(!result.is_error);
1399 assert_eq!(result.content, "ok");
1400 }
1401
1402 #[tokio::test]
1403 async fn test_medium_risk_tool_no_second_approval() {
1404 let registry = ToolRegistry {
1405 config: crate::config::Config::test_defaults(),
1406 sandbox_mode: SandboxMode::Off,
1407 sandbox_runtime_available: false,
1408 cached_static_definitions: OnceLock::new(),
1409 audit_db: None,
1410 risk_overrides: std::collections::HashMap::new(),
1411 tools: vec![Box::new(DummyTool {
1412 tool_name: "write_file".into(),
1413 })],
1414 };
1415 let auth = ToolAuthContext {
1416 caller_channel: "web".into(),
1417 caller_chat_id: 1,
1418 principal: "main".into(),
1419 control_chat_ids: vec![],
1420 env_files: vec![],
1421 };
1422
1423 let result = registry
1424 .execute_with_auth("write_file", json!({}), &auth)
1425 .await;
1426 assert!(!result.is_error);
1427 assert_eq!(result.content, "ok");
1428 }
1429
1430 #[tokio::test]
1431 async fn test_dynamic_plugin_tool_executes_without_restart() {
1432 let root = std::env::temp_dir().join(format!("microclaw_plugin_{}", uuid::Uuid::new_v4()));
1433 let plugins_dir = root.join("plugins");
1434 std::fs::create_dir_all(&plugins_dir).unwrap();
1435 let manifest = plugins_dir.join("demo.yaml");
1436 std::fs::write(
1437 &manifest,
1438 r#"
1439name: demo
1440enabled: true
1441tools:
1442 - name: plugin_runtime_echo
1443 description: runtime echo
1444 input_schema:
1445 type: object
1446 properties: {}
1447 required: []
1448 run:
1449 command: "echo plugin-ok"
1450 timeout_secs: 5
1451"#,
1452 )
1453 .unwrap();
1454
1455 let mut config = crate::config::Config::test_defaults();
1456 config.working_dir = root.join("work").to_string_lossy().to_string();
1457 config.plugins.enabled = true;
1458 config.plugins.dir = Some(plugins_dir.to_string_lossy().to_string());
1459
1460 let registry = ToolRegistry {
1461 config,
1462 tools: vec![],
1463 sandbox_mode: SandboxMode::Off,
1464 sandbox_runtime_available: false,
1465 cached_static_definitions: OnceLock::new(),
1466 audit_db: None,
1467 risk_overrides: std::collections::HashMap::new(),
1468 };
1469 let auth = ToolAuthContext {
1470 caller_channel: "web".into(),
1471 caller_chat_id: 7,
1472 principal: "main".into(),
1473 control_chat_ids: vec![],
1474 env_files: vec![],
1475 };
1476
1477 let defs = registry.definitions();
1478 assert!(defs.iter().any(|d| d.name == "plugin_runtime_echo"));
1479
1480 let result = registry
1481 .execute_with_auth("plugin_runtime_echo", json!({}), &auth)
1482 .await;
1483 assert!(!result.is_error, "{}", result.content);
1484 assert!(result.content.contains("plugin-ok"));
1485
1486 let _ = std::fs::remove_dir_all(root);
1487 }
1488
1489 #[tokio::test]
1490 async fn test_injects_default_chat_id_for_memory_tools() {
1491 let registry = ToolRegistry {
1492 config: crate::config::Config::test_defaults(),
1493 sandbox_mode: SandboxMode::Off,
1494 sandbox_runtime_available: false,
1495 cached_static_definitions: OnceLock::new(),
1496 audit_db: None,
1497 risk_overrides: std::collections::HashMap::new(),
1498 tools: vec![Box::new(CaptureInputTool {
1499 tool_name: "write_memory".into(),
1500 })],
1501 };
1502 let auth = ToolAuthContext {
1503 caller_channel: "feishu".into(),
1504 caller_chat_id: 8009499081,
1505 principal: "main".into(),
1506 control_chat_ids: vec![],
1507 env_files: vec![],
1508 };
1509
1510 let result = registry
1511 .execute_with_auth(
1512 "write_memory",
1513 json!({"scope":"chat","content":"hello"}),
1514 &auth,
1515 )
1516 .await;
1517 assert!(!result.is_error);
1518 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1519 assert_eq!(payload["chat_id"].as_i64(), Some(8009499081));
1520 }
1521
1522 #[tokio::test]
1523 async fn test_does_not_override_existing_chat_id() {
1524 let registry = ToolRegistry {
1525 config: crate::config::Config::test_defaults(),
1526 sandbox_mode: SandboxMode::Off,
1527 sandbox_runtime_available: false,
1528 cached_static_definitions: OnceLock::new(),
1529 audit_db: None,
1530 risk_overrides: std::collections::HashMap::new(),
1531 tools: vec![Box::new(CaptureInputTool {
1532 tool_name: "write_memory".into(),
1533 })],
1534 };
1535 let auth = ToolAuthContext {
1536 caller_channel: "feishu".into(),
1537 caller_chat_id: 8009499081,
1538 principal: "main".into(),
1539 control_chat_ids: vec![],
1540 env_files: vec![],
1541 };
1542
1543 let result = registry
1544 .execute_with_auth(
1545 "write_memory",
1546 json!({"scope":"chat","chat_id":42,"content":"hello"}),
1547 &auth,
1548 )
1549 .await;
1550 assert!(!result.is_error);
1551 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1552 assert_eq!(payload["chat_id"].as_i64(), Some(42));
1553 }
1554
1555 #[tokio::test]
1556 async fn test_injects_default_chat_id_for_send_message_tool() {
1557 let registry = ToolRegistry {
1558 config: crate::config::Config::test_defaults(),
1559 sandbox_mode: SandboxMode::Off,
1560 sandbox_runtime_available: false,
1561 cached_static_definitions: OnceLock::new(),
1562 audit_db: None,
1563 risk_overrides: std::collections::HashMap::new(),
1564 tools: vec![Box::new(CaptureInputTool {
1565 tool_name: "send_message".into(),
1566 })],
1567 };
1568 let auth = ToolAuthContext {
1569 caller_channel: "web".into(),
1570 caller_chat_id: 9001,
1571 principal: "main".into(),
1572 control_chat_ids: vec![],
1573 env_files: vec![],
1574 };
1575
1576 let result = registry
1577 .execute_with_auth("send_message", json!({"text":"progress update"}), &auth)
1578 .await;
1579 assert!(!result.is_error);
1580 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1581 assert_eq!(payload["chat_id"].as_i64(), Some(9001));
1582 assert_eq!(payload["text"].as_str(), Some("progress update"));
1583 }
1584}