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