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