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 fn definitions_for(&self, auth: &ToolAuthContext) -> Vec<ToolDefinition> {
698 self.definitions()
699 .into_iter()
700 .filter(|definition| {
701 !matches!(
702 crate::tool_guardrails::evaluate_tool_policy_for_auth_with_risk(
703 &self.config.tool_policy,
704 &definition.name,
705 auth,
706 self.effective_tool_risk(&definition.name),
707 ),
708 crate::tool_guardrails::PolicyDecision::Block(_)
709 )
710 })
711 .collect()
712 }
713
714 pub async fn execute(&self, name: &str, input: serde_json::Value) -> ToolResult {
715 for tool in &self.tools {
716 if tool.name() == name {
717 let started = Instant::now();
718 let mut result = tool.execute(input).await;
719 result.duration_ms = Some(started.elapsed().as_millis());
720 result.bytes = result.content.len();
721 if result.is_error && result.error_type.is_none() {
722 result.error_type = Some("tool_error".to_string());
723 }
724 if result.status_code.is_none() {
725 result.status_code = Some(if result.is_error { 1 } else { 0 });
726 }
727 return result;
728 }
729 }
730 ToolResult::error(format!("Unknown tool: {name}")).with_error_type("unknown_tool")
731 }
732
733 pub async fn execute_with_auth(
734 &self,
735 name: &str,
736 input: serde_json::Value,
737 auth: &ToolAuthContext,
738 ) -> ToolResult {
739 if auth.caller_channel == "work"
740 && auth.principal.starts_with("subagent:")
741 && !matches!(
742 name,
743 "web_search"
744 | "web_fetch"
745 | "deep_research"
746 | "read_memory"
747 | "structured_memory_search"
748 | "session_search"
749 | "read_file"
750 | "grep"
751 | "glob"
752 | "time_math"
753 | "get_current_time"
754 | "compare_time"
755 | "calculate"
756 | "osv_check"
757 | "fetch_artifact"
758 | "describe_image"
759 | "consult_specialist"
760 | "report_progress"
761 | "subagents_list"
762 | "subagents_info"
763 )
764 {
765 return ToolResult::error(format!(
766 "Tool `{name}` is not available to Work Subagents; Workspace writes are owned by the Main Agent"
767 ))
768 .with_error_type("work_subagent_read_only");
769 }
770 if auth.principal == "learning_foundry"
771 && !matches!(
772 name,
773 "web_search"
774 | "web_fetch"
775 | "deep_research"
776 | "read_memory"
777 | "session_search"
778 | "read_file"
779 | "grep"
780 | "glob"
781 | "time_math"
782 | "get_current_time"
783 | "compare_time"
784 | "calculate"
785 )
786 {
787 return ToolResult::error(format!(
788 "Tool `{name}` is not available to the read-only Learning Foundry principal"
789 ))
790 .with_error_type("learning_foundry_read_only");
791 }
792 let effective_risk = self.effective_tool_risk(name);
798 match crate::tool_guardrails::evaluate_tool_policy_for_auth_with_risk(
799 &self.config.tool_policy,
800 name,
801 auth,
802 effective_risk,
803 ) {
804 crate::tool_guardrails::PolicyDecision::Allow => {}
805 decision => {
806 let (action, status, block_reason) = match decision {
807 crate::tool_guardrails::PolicyDecision::Warn(reason) => {
808 ("warn", "allowed", Err(reason))
809 }
810 crate::tool_guardrails::PolicyDecision::Block(reason) => {
811 ("block", "blocked", Ok(reason))
812 }
813 crate::tool_guardrails::PolicyDecision::Allow => unreachable!(),
814 };
815 let reason = match &block_reason {
816 Ok(r) | Err(r) => r.clone(),
817 };
818 tracing::warn!(
819 tool = name,
820 chat_id = auth.caller_chat_id,
821 principal = %auth.principal,
822 action,
823 %reason,
824 "Tool policy violation"
825 );
826 if let Some(db) = self.audit_db.clone() {
827 let tool = name.to_string();
828 let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
829 let detail = reason.clone();
830 tokio::spawn(async move {
831 let _ = crate::internal::storage::db::call_blocking(db, move |db| {
832 db.log_audit_event(
833 "tool_policy",
834 &actor,
835 action,
836 Some(&tool),
837 status,
838 Some(&detail),
839 )
840 })
841 .await;
842 });
843 }
844 if block_reason.is_ok() {
845 return ToolResult::error(format!(
846 "Tool call blocked by policy: {reason}. Do not retry this tool; \
847 use a permitted tool or explain the limitation to the user."
848 ))
849 .with_error_type("tool_policy_blocked");
850 }
851 }
852 }
853 for (url, decision) in crate::internal::tool_runtime::egress::evaluate_tool_input(
854 &self.config.egress_policy,
855 &input,
856 ) {
857 let (action, status, reason, blocked) = match decision {
858 crate::internal::tool_runtime::egress::EgressDecision::Allow => continue,
859 crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
860 ("warn", "allowed", reason, false)
861 }
862 crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
863 ("block", "blocked", reason, true)
864 }
865 };
866 tracing::warn!(
867 tool = name,
868 chat_id = auth.caller_chat_id,
869 principal = %auth.principal,
870 %url,
871 action,
872 %reason,
873 "Egress policy violation"
874 );
875 if let Some(db) = self.audit_db.clone() {
876 let tool = name.to_string();
877 let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
878 let detail = format!("{reason}; url={url}");
879 tokio::spawn(async move {
880 let _ = crate::internal::storage::db::call_blocking(db, move |db| {
881 db.log_audit_event(
882 "egress_policy",
883 &actor,
884 action,
885 Some(&tool),
886 status,
887 Some(&detail),
888 )
889 })
890 .await;
891 });
892 }
893 if blocked {
894 return ToolResult::error(format!(
895 "Tool call blocked by egress policy: {reason}. Do not retry this destination."
896 ))
897 .with_error_type("egress_policy_blocked");
898 }
899 }
900 if let Err(msg) =
901 validate_execution_policy(name, self.sandbox_mode, self.sandbox_runtime_available)
902 {
903 return ToolResult::error(msg).with_error_type("execution_policy_blocked");
904 }
905 let mut input = input;
906 if self.config.high_risk_tool_user_confirmation_required {
907 let gateable = effective_risk == ToolRisk::High || name == "a2a_send";
913 if gateable {
914 let standing = if let Some(db) = self.audit_db.clone() {
921 let key = format!("approved_tool:{}:{}", auth.caller_chat_id, name);
922 crate::internal::storage::db::call_blocking(db, move |db| {
923 db.get_runtime_meta(&key)
924 })
925 .await
926 .ok()
927 .flatten()
928 .is_some()
929 } else {
930 false
931 };
932 if standing {
933 tracing::info!(
934 tool = name,
935 chat_id = auth.caller_chat_id,
936 "High-risk tool allowed by standing per-chat approval"
937 );
938 if let serde_json::Value::Object(map) = &mut input {
939 map.insert(
940 "__microclaw_high_risk_approved".to_string(),
941 serde_json::Value::Bool(true),
942 );
943 }
944 } else if let Some(blocked) =
945 require_high_risk_approval_with_risk(name, effective_risk, auth, &input)
946 {
947 return blocked;
948 }
949 }
950 }
951
952 tracing::debug!(
953 tool = name,
954 risk = effective_risk.as_str(),
955 execution_policy = tool_execution_policy(name).as_str(),
956 sandbox_mode = ?self.sandbox_mode,
957 sandbox_runtime_available = self.sandbox_runtime_available,
958 "tool execution policy evaluated"
959 );
960 let input = Self::inject_default_chat_id_if_missing(name, input, auth);
961 let input = inject_auth_context(input, auth);
962 let result = self.execute(name, input.clone()).await;
963 if result.error_type.as_deref() == Some("unknown_tool") {
964 if let Some(dynamic) =
965 crate::plugins::execute_dynamic_plugin_tool(&self.config, name, input).await
966 {
967 return dynamic;
968 }
969 }
970 result
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use crate::config::WorkingDirIsolation;
978 use async_trait::async_trait;
979 use serde_json::json;
980
981 #[test]
982 fn test_tool_result_success() {
983 let r = ToolResult::success("ok".into());
984 assert_eq!(r.content, "ok");
985 assert!(!r.is_error);
986 }
987
988 #[test]
989 fn test_tool_result_error() {
990 let r = ToolResult::error("fail".into());
991 assert_eq!(r.content, "fail");
992 assert!(r.is_error);
993 }
994
995 #[test]
996 fn test_schema_object() {
997 let schema = schema_object(
998 json!({
999 "name": {"type": "string"},
1000 "age": {"type": "integer"}
1001 }),
1002 &["name"],
1003 );
1004 assert_eq!(schema["type"], "object");
1005 assert!(schema["properties"]["name"].is_object());
1006 assert!(schema["properties"]["age"].is_object());
1007 let required = schema["required"].as_array().unwrap();
1008 assert_eq!(required.len(), 1);
1009 assert_eq!(required[0], "name");
1010 }
1011
1012 #[test]
1013 fn test_schema_object_empty_required() {
1014 let schema = schema_object(json!({}), &[]);
1015 let required = schema["required"].as_array().unwrap();
1016 assert!(required.is_empty());
1017 }
1018
1019 #[test]
1020 fn test_auth_context_from_input() {
1021 let input = json!({
1022 "__microclaw_auth": {
1023 "caller_channel": "telegram",
1024 "caller_chat_id": 123,
1025 "control_chat_ids": [123, 999]
1026 }
1027 });
1028 let auth = auth_context_from_input(&input).unwrap();
1029 assert_eq!(auth.caller_channel, "telegram");
1030 assert_eq!(auth.caller_chat_id, 123);
1031 assert!(auth.is_control_chat());
1032 assert!(auth.can_access_chat(456));
1033 }
1034
1035 #[test]
1036 fn test_authorize_chat_access_denied() {
1037 let input = json!({
1038 "__microclaw_auth": {
1039 "caller_channel": "telegram",
1040 "caller_chat_id": 100,
1041 "control_chat_ids": []
1042 }
1043 });
1044 let err = authorize_chat_access(&input, 200).unwrap_err();
1045 assert!(err.contains("Permission denied"));
1046 }
1047
1048 #[test]
1049 fn test_resolve_tool_working_dir_shared() {
1050 let dir = resolve_tool_working_dir(
1051 std::path::Path::new("/tmp/work"),
1052 WorkingDirIsolation::Shared,
1053 &json!({
1054 "__microclaw_auth": {
1055 "caller_channel": "telegram",
1056 "caller_chat_id": 123,
1057 "control_chat_ids": []
1058 }
1059 }),
1060 );
1061 assert_eq!(dir, std::path::PathBuf::from("/tmp/work/shared"));
1062 }
1063
1064 #[test]
1065 fn test_resolve_tool_working_dir_direct() {
1066 let dir = resolve_tool_working_dir(
1067 std::path::Path::new("/tmp/work"),
1068 WorkingDirIsolation::Direct,
1069 &json!({
1070 "__microclaw_auth": {
1071 "caller_channel": "headless",
1072 "caller_chat_id": 123,
1073 "control_chat_ids": []
1074 }
1075 }),
1076 );
1077 assert_eq!(dir, std::path::PathBuf::from("/tmp/work"));
1078 }
1079
1080 #[test]
1081 fn test_resolve_tool_working_dir_chat() {
1082 let dir = resolve_tool_working_dir(
1083 std::path::Path::new("/tmp/work"),
1084 WorkingDirIsolation::Chat,
1085 &json!({
1086 "__microclaw_auth": {
1087 "caller_channel": "discord",
1088 "caller_chat_id": -100123,
1089 "control_chat_ids": []
1090 }
1091 }),
1092 );
1093 assert_eq!(
1094 dir,
1095 std::path::PathBuf::from("/tmp/work/chat/discord/neg100123")
1096 );
1097 }
1098
1099 struct DummyTool {
1100 tool_name: String,
1101 }
1102
1103 #[async_trait]
1104 impl Tool for DummyTool {
1105 fn name(&self) -> &str {
1106 &self.tool_name
1107 }
1108
1109 fn definition(&self) -> ToolDefinition {
1110 ToolDefinition {
1111 name: self.tool_name.clone(),
1112 description: "dummy".into(),
1113 input_schema: schema_object(json!({}), &[]),
1114 }
1115 }
1116
1117 async fn execute(&self, _input: serde_json::Value) -> ToolResult {
1118 ToolResult::success("ok".into())
1119 }
1120 }
1121
1122 struct CaptureInputTool {
1123 tool_name: String,
1124 }
1125
1126 #[async_trait]
1127 impl Tool for CaptureInputTool {
1128 fn name(&self) -> &str {
1129 &self.tool_name
1130 }
1131
1132 fn definition(&self) -> ToolDefinition {
1133 ToolDefinition {
1134 name: self.tool_name.clone(),
1135 description: "capture".into(),
1136 input_schema: schema_object(json!({}), &[]),
1137 }
1138 }
1139
1140 async fn execute(&self, input: serde_json::Value) -> ToolResult {
1141 ToolResult::success(input.to_string())
1142 }
1143 }
1144
1145 #[test]
1146 fn test_tool_risk_levels() {
1147 assert_eq!(tool_risk("bash"), ToolRisk::High);
1148 assert_eq!(tool_risk("write_file"), ToolRisk::Medium);
1149 assert_eq!(tool_risk("pause_scheduled_task"), ToolRisk::Medium);
1150 assert_eq!(tool_risk("sync_skills"), ToolRisk::Medium);
1151 assert_eq!(tool_risk("read_file"), ToolRisk::Low);
1152 }
1153
1154 #[tokio::test]
1155 async fn test_tool_policy_blocks_at_choke_point() {
1156 let mut config = crate::config::Config::test_defaults();
1160 config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
1161 config.tool_policy.deny_tools = vec!["bash".to_string()];
1162 let registry = ToolRegistry {
1163 config,
1164 sandbox_mode: SandboxMode::Off,
1165 sandbox_runtime_available: false,
1166 cached_static_definitions: OnceLock::new(),
1167 audit_db: None,
1168 risk_overrides: std::collections::HashMap::new(),
1169 tools: vec![Box::new(DummyTool {
1170 tool_name: "bash".into(),
1171 })],
1172 };
1173 let auth = ToolAuthContext {
1174 caller_channel: "telegram".into(),
1175 caller_chat_id: 1,
1176 principal: "main".into(),
1177 control_chat_ids: vec![],
1178 env_files: vec![],
1179 };
1180 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1181 assert!(result.is_error);
1182 assert_eq!(result.error_type.as_deref(), Some("tool_policy_blocked"));
1183
1184 let mut warn_config = crate::config::Config::test_defaults();
1186 warn_config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Warn;
1187 warn_config.tool_policy.deny_tools = vec!["bash".to_string()];
1188 warn_config.high_risk_tool_user_confirmation_required = false;
1189 let registry = ToolRegistry {
1190 config: warn_config,
1191 sandbox_mode: SandboxMode::Off,
1192 sandbox_runtime_available: false,
1193 cached_static_definitions: OnceLock::new(),
1194 audit_db: None,
1195 risk_overrides: std::collections::HashMap::new(),
1196 tools: vec![Box::new(DummyTool {
1197 tool_name: "bash".into(),
1198 })],
1199 };
1200 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1201 assert!(
1202 !result.is_error,
1203 "warn mode must still execute: {}",
1204 result.content
1205 );
1206 }
1207
1208 #[tokio::test]
1209 async fn learning_foundry_principal_is_read_only_at_choke_point() {
1210 let registry = ToolRegistry {
1211 config: crate::config::Config::test_defaults(),
1212 sandbox_mode: SandboxMode::Off,
1213 sandbox_runtime_available: false,
1214 cached_static_definitions: OnceLock::new(),
1215 audit_db: None,
1216 risk_overrides: std::collections::HashMap::new(),
1217 tools: vec![
1218 Box::new(DummyTool {
1219 tool_name: "write_file".into(),
1220 }),
1221 Box::new(DummyTool {
1222 tool_name: "web_search".into(),
1223 }),
1224 ],
1225 };
1226 let auth = ToolAuthContext {
1227 caller_channel: "learning_foundry".into(),
1228 caller_chat_id: -7,
1229 principal: "learning_foundry".into(),
1230 control_chat_ids: vec![],
1231 env_files: vec![],
1232 };
1233 let write = registry
1234 .execute_with_auth("write_file", json!({}), &auth)
1235 .await;
1236 assert!(write.is_error);
1237 assert_eq!(
1238 write.error_type.as_deref(),
1239 Some("learning_foundry_read_only")
1240 );
1241 let search = registry
1242 .execute_with_auth("web_search", json!({}), &auth)
1243 .await;
1244 assert!(!search.is_error);
1245 }
1246
1247 #[tokio::test]
1248 async fn work_subagent_is_read_only_but_main_agent_can_write() {
1249 let make_registry = || ToolRegistry {
1250 config: crate::config::Config::test_defaults(),
1251 sandbox_mode: SandboxMode::Off,
1252 sandbox_runtime_available: false,
1253 cached_static_definitions: OnceLock::new(),
1254 audit_db: None,
1255 risk_overrides: std::collections::HashMap::new(),
1256 tools: vec![
1257 Box::new(DummyTool {
1258 tool_name: "write_file".into(),
1259 }),
1260 Box::new(DummyTool {
1261 tool_name: "read_file".into(),
1262 }),
1263 ],
1264 };
1265 let mut auth = ToolAuthContext {
1266 caller_channel: "work".into(),
1267 caller_chat_id: 1,
1268 principal: "subagent:run-1".into(),
1269 control_chat_ids: vec![],
1270 env_files: vec![],
1271 };
1272
1273 let denied = make_registry()
1274 .execute_with_auth("write_file", json!({}), &auth)
1275 .await;
1276 assert!(denied.is_error);
1277 assert_eq!(
1278 denied.error_type.as_deref(),
1279 Some("work_subagent_read_only")
1280 );
1281 let read = make_registry()
1282 .execute_with_auth("read_file", json!({}), &auth)
1283 .await;
1284 assert!(!read.is_error);
1285
1286 auth.principal = "main".into();
1287 let write = make_registry()
1288 .execute_with_auth("write_file", json!({}), &auth)
1289 .await;
1290 assert!(!write.is_error);
1291 }
1292
1293 #[tokio::test]
1294 async fn test_high_risk_tool_requires_explicit_approval_on_web() {
1295 let registry = ToolRegistry {
1296 config: crate::config::Config::test_defaults(),
1297 sandbox_mode: SandboxMode::Off,
1298 sandbox_runtime_available: false,
1299 cached_static_definitions: OnceLock::new(),
1300 audit_db: None,
1301 risk_overrides: std::collections::HashMap::new(),
1302 tools: vec![Box::new(DummyTool {
1303 tool_name: "bash".into(),
1304 })],
1305 };
1306 let auth = ToolAuthContext {
1307 caller_channel: "web".into(),
1308 caller_chat_id: 1,
1309 principal: "main".into(),
1310 control_chat_ids: vec![],
1311 env_files: vec![],
1312 };
1313
1314 let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1315 assert!(first.is_error);
1316 assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1317
1318 let second = registry.execute_with_auth("bash", json!({}), &auth).await;
1319 assert!(second.is_error);
1320 assert_eq!(second.error_type.as_deref(), Some("approval_required"));
1321
1322 let approved = registry
1323 .execute_with_auth(
1324 "bash",
1325 json!({"__microclaw_high_risk_approved": true}),
1326 &auth,
1327 )
1328 .await;
1329 assert!(!approved.is_error);
1330 assert_eq!(approved.content, "ok");
1331 }
1332
1333 #[tokio::test]
1334 async fn test_high_risk_tool_requires_explicit_approval_on_control_chat() {
1335 let registry = ToolRegistry {
1336 config: crate::config::Config::test_defaults(),
1337 sandbox_mode: SandboxMode::Off,
1338 sandbox_runtime_available: false,
1339 cached_static_definitions: OnceLock::new(),
1340 audit_db: None,
1341 risk_overrides: std::collections::HashMap::new(),
1342 tools: vec![Box::new(DummyTool {
1343 tool_name: "bash".into(),
1344 })],
1345 };
1346 let auth = ToolAuthContext {
1347 caller_channel: "telegram".into(),
1348 caller_chat_id: 123,
1349 principal: "main".into(),
1350 control_chat_ids: vec![123],
1351 env_files: vec![],
1352 };
1353
1354 let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1355 assert!(first.is_error);
1356 assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1357
1358 let approved = registry
1359 .execute_with_auth(
1360 "bash",
1361 json!({"__microclaw_high_risk_approved": true}),
1362 &auth,
1363 )
1364 .await;
1365 assert!(!approved.is_error);
1366 assert_eq!(approved.content, "ok");
1367 }
1368
1369 #[tokio::test]
1370 async fn test_high_risk_tool_confirmation_flag_false_hard_disables_web_approval_gate() {
1371 let mut config = crate::config::Config::test_defaults();
1372 config.high_risk_tool_user_confirmation_required = false;
1373 let registry = ToolRegistry {
1374 config,
1375 sandbox_mode: SandboxMode::Off,
1376 sandbox_runtime_available: false,
1377 cached_static_definitions: OnceLock::new(),
1378 audit_db: None,
1379 risk_overrides: std::collections::HashMap::new(),
1380 tools: vec![Box::new(DummyTool {
1381 tool_name: "bash".into(),
1382 })],
1383 };
1384 let auth = ToolAuthContext {
1385 caller_channel: "web".into(),
1386 caller_chat_id: 1,
1387 principal: "main".into(),
1388 control_chat_ids: vec![],
1389 env_files: vec![],
1390 };
1391
1392 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1393 assert!(!result.is_error);
1394 assert_eq!(result.content, "ok");
1395 }
1396
1397 #[tokio::test]
1398 async fn test_high_risk_tool_confirmation_flag_false_hard_disables_control_chat_approval_gate()
1399 {
1400 let mut config = crate::config::Config::test_defaults();
1401 config.high_risk_tool_user_confirmation_required = false;
1402 let registry = ToolRegistry {
1403 config,
1404 sandbox_mode: SandboxMode::Off,
1405 sandbox_runtime_available: false,
1406 cached_static_definitions: OnceLock::new(),
1407 audit_db: None,
1408 risk_overrides: std::collections::HashMap::new(),
1409 tools: vec![Box::new(DummyTool {
1410 tool_name: "bash".into(),
1411 })],
1412 };
1413 let auth = ToolAuthContext {
1414 caller_channel: "telegram".into(),
1415 caller_chat_id: 123,
1416 principal: "main".into(),
1417 control_chat_ids: vec![123],
1418 env_files: vec![],
1419 };
1420
1421 let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1422 assert!(!result.is_error);
1423 assert_eq!(result.content, "ok");
1424 }
1425
1426 #[tokio::test]
1427 async fn test_medium_risk_tool_no_second_approval() {
1428 let registry = ToolRegistry {
1429 config: crate::config::Config::test_defaults(),
1430 sandbox_mode: SandboxMode::Off,
1431 sandbox_runtime_available: false,
1432 cached_static_definitions: OnceLock::new(),
1433 audit_db: None,
1434 risk_overrides: std::collections::HashMap::new(),
1435 tools: vec![Box::new(DummyTool {
1436 tool_name: "write_file".into(),
1437 })],
1438 };
1439 let auth = ToolAuthContext {
1440 caller_channel: "web".into(),
1441 caller_chat_id: 1,
1442 principal: "main".into(),
1443 control_chat_ids: vec![],
1444 env_files: vec![],
1445 };
1446
1447 let result = registry
1448 .execute_with_auth("write_file", json!({}), &auth)
1449 .await;
1450 assert!(!result.is_error);
1451 assert_eq!(result.content, "ok");
1452 }
1453
1454 #[tokio::test]
1455 async fn test_dynamic_plugin_tool_executes_without_restart() {
1456 let root = std::env::temp_dir().join(format!("microclaw_plugin_{}", uuid::Uuid::new_v4()));
1457 let plugins_dir = root.join("plugins");
1458 std::fs::create_dir_all(&plugins_dir).unwrap();
1459 let manifest = plugins_dir.join("demo.yaml");
1460 std::fs::write(
1461 &manifest,
1462 r#"
1463name: demo
1464enabled: true
1465tools:
1466 - name: plugin_runtime_echo
1467 description: runtime echo
1468 input_schema:
1469 type: object
1470 properties: {}
1471 required: []
1472 run:
1473 command: "echo plugin-ok"
1474 timeout_secs: 5
1475"#,
1476 )
1477 .unwrap();
1478
1479 let mut config = crate::config::Config::test_defaults();
1480 config.working_dir = root.join("work").to_string_lossy().to_string();
1481 config.plugins.enabled = true;
1482 config.plugins.dir = Some(plugins_dir.to_string_lossy().to_string());
1483
1484 let registry = ToolRegistry {
1485 config,
1486 tools: vec![],
1487 sandbox_mode: SandboxMode::Off,
1488 sandbox_runtime_available: false,
1489 cached_static_definitions: OnceLock::new(),
1490 audit_db: None,
1491 risk_overrides: std::collections::HashMap::new(),
1492 };
1493 let auth = ToolAuthContext {
1494 caller_channel: "web".into(),
1495 caller_chat_id: 7,
1496 principal: "main".into(),
1497 control_chat_ids: vec![],
1498 env_files: vec![],
1499 };
1500
1501 let defs = registry.definitions();
1502 assert!(defs.iter().any(|d| d.name == "plugin_runtime_echo"));
1503
1504 let result = registry
1505 .execute_with_auth("plugin_runtime_echo", json!({}), &auth)
1506 .await;
1507 assert!(!result.is_error, "{}", result.content);
1508 assert!(result.content.contains("plugin-ok"));
1509
1510 let _ = std::fs::remove_dir_all(root);
1511 }
1512
1513 #[test]
1514 fn test_definitions_for_hides_policy_blocked_tools() {
1515 let mut config = crate::config::Config::test_defaults();
1516 config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
1517 config.tool_policy.allow_tools = vec!["read_*".to_string()];
1518 let tools: Vec<Box<dyn Tool>> = ["read_file", "bash", "write_file"]
1519 .into_iter()
1520 .map(|name| {
1521 Box::new(CaptureInputTool {
1522 tool_name: name.to_string(),
1523 }) as Box<dyn Tool>
1524 })
1525 .collect();
1526 let registry = ToolRegistry {
1527 config,
1528 sandbox_mode: SandboxMode::Off,
1529 sandbox_runtime_available: false,
1530 cached_static_definitions: OnceLock::new(),
1531 audit_db: None,
1532 risk_overrides: std::collections::HashMap::new(),
1533 tools,
1534 };
1535 let auth = ToolAuthContext {
1536 caller_channel: "telegram".into(),
1537 caller_chat_id: 1,
1538 principal: "main".into(),
1539 control_chat_ids: vec![],
1540 env_files: vec![],
1541 };
1542
1543 assert_eq!(registry.definitions().len(), 3);
1546 let visible: Vec<String> = registry
1547 .definitions_for(&auth)
1548 .into_iter()
1549 .map(|d| d.name)
1550 .collect();
1551 assert_eq!(visible, vec!["read_file".to_string()]);
1552
1553 let mut warn_registry = registry;
1555 warn_registry.config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Warn;
1556 assert_eq!(warn_registry.definitions_for(&auth).len(), 3);
1557 }
1558
1559 #[tokio::test]
1560 async fn test_allow_list_blocks_unlisted_tool_at_choke_point() {
1561 let mut config = crate::config::Config::test_defaults();
1564 config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
1565 config.tool_policy.allow_tools = vec!["read_file".to_string()];
1566 let registry = ToolRegistry {
1567 config,
1568 sandbox_mode: SandboxMode::Off,
1569 sandbox_runtime_available: false,
1570 cached_static_definitions: OnceLock::new(),
1571 audit_db: None,
1572 risk_overrides: std::collections::HashMap::new(),
1573 tools: vec![
1574 Box::new(CaptureInputTool {
1575 tool_name: "write_file".into(),
1576 }),
1577 Box::new(CaptureInputTool {
1578 tool_name: "read_file".into(),
1579 }),
1580 ],
1581 };
1582 let auth = ToolAuthContext {
1583 caller_channel: "telegram".into(),
1584 caller_chat_id: 1,
1585 principal: "main".into(),
1586 control_chat_ids: vec![],
1587 env_files: vec![],
1588 };
1589 let blocked = registry
1590 .execute_with_auth("write_file", json!({}), &auth)
1591 .await;
1592 assert!(blocked.is_error);
1593 assert_eq!(blocked.error_type.as_deref(), Some("tool_policy_blocked"));
1594 assert!(blocked.content.contains("not in tool_policy.allow_tools"));
1595
1596 let allowed = registry
1597 .execute_with_auth("read_file", json!({}), &auth)
1598 .await;
1599 assert!(!allowed.is_error, "{}", allowed.content);
1600 }
1601
1602 #[tokio::test]
1603 async fn test_injects_default_chat_id_for_memory_tools() {
1604 let registry = ToolRegistry {
1605 config: crate::config::Config::test_defaults(),
1606 sandbox_mode: SandboxMode::Off,
1607 sandbox_runtime_available: false,
1608 cached_static_definitions: OnceLock::new(),
1609 audit_db: None,
1610 risk_overrides: std::collections::HashMap::new(),
1611 tools: vec![Box::new(CaptureInputTool {
1612 tool_name: "write_memory".into(),
1613 })],
1614 };
1615 let auth = ToolAuthContext {
1616 caller_channel: "feishu".into(),
1617 caller_chat_id: 8009499081,
1618 principal: "main".into(),
1619 control_chat_ids: vec![],
1620 env_files: vec![],
1621 };
1622
1623 let result = registry
1624 .execute_with_auth(
1625 "write_memory",
1626 json!({"scope":"chat","content":"hello"}),
1627 &auth,
1628 )
1629 .await;
1630 assert!(!result.is_error);
1631 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1632 assert_eq!(payload["chat_id"].as_i64(), Some(8009499081));
1633 }
1634
1635 #[tokio::test]
1636 async fn test_does_not_override_existing_chat_id() {
1637 let registry = ToolRegistry {
1638 config: crate::config::Config::test_defaults(),
1639 sandbox_mode: SandboxMode::Off,
1640 sandbox_runtime_available: false,
1641 cached_static_definitions: OnceLock::new(),
1642 audit_db: None,
1643 risk_overrides: std::collections::HashMap::new(),
1644 tools: vec![Box::new(CaptureInputTool {
1645 tool_name: "write_memory".into(),
1646 })],
1647 };
1648 let auth = ToolAuthContext {
1649 caller_channel: "feishu".into(),
1650 caller_chat_id: 8009499081,
1651 principal: "main".into(),
1652 control_chat_ids: vec![],
1653 env_files: vec![],
1654 };
1655
1656 let result = registry
1657 .execute_with_auth(
1658 "write_memory",
1659 json!({"scope":"chat","chat_id":42,"content":"hello"}),
1660 &auth,
1661 )
1662 .await;
1663 assert!(!result.is_error);
1664 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1665 assert_eq!(payload["chat_id"].as_i64(), Some(42));
1666 }
1667
1668 #[tokio::test]
1669 async fn test_injects_default_chat_id_for_send_message_tool() {
1670 let registry = ToolRegistry {
1671 config: crate::config::Config::test_defaults(),
1672 sandbox_mode: SandboxMode::Off,
1673 sandbox_runtime_available: false,
1674 cached_static_definitions: OnceLock::new(),
1675 audit_db: None,
1676 risk_overrides: std::collections::HashMap::new(),
1677 tools: vec![Box::new(CaptureInputTool {
1678 tool_name: "send_message".into(),
1679 })],
1680 };
1681 let auth = ToolAuthContext {
1682 caller_channel: "web".into(),
1683 caller_chat_id: 9001,
1684 principal: "main".into(),
1685 control_chat_ids: vec![],
1686 env_files: vec![],
1687 };
1688
1689 let result = registry
1690 .execute_with_auth("send_message", json!({"text":"progress update"}), &auth)
1691 .await;
1692 assert!(!result.is_error);
1693 let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1694 assert_eq!(payload["chat_id"].as_i64(), Some(9001));
1695 assert_eq!(payload["text"].as_str(), Some("progress update"));
1696 }
1697}