Skip to main content

microclaw_engine/tools/
mod.rs

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
46/// Tools that are read-only / side-effect-free for the same arguments. Used
47/// by the per-turn guardrail controller (`tool_guardrails.rs`) to detect
48/// "no progress" loops where the model keeps re-running the same query and
49/// getting the same result. NOT a security boundary — that's `tool_risk` /
50/// `tool_execution_policy` in the runtime crate.
51pub 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    /// For sealing tool-policy decisions into the audit chain. `None` only in
91    /// unit tests; both real constructors set it.
92    audit_db: Option<Arc<Database>>,
93    /// Registration-time risk overrides (e.g. MCP server trust tiers).
94    /// Consulted before the name-derived `tool_risk` at the policy and
95    /// approval choke points.
96    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        // Add ClawHub tools if enabled
393        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    /// Create a restricted tool registry for sub-agents.
414    /// When `allow_session_tools` is true, orchestration tools are exposed for depth-limited child spawning.
415    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        // Visual creation + progress reporting: available to specialists whenever a
521        // channel registry is present, independent of session-spawn permissions.
522        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    /// Paths the generic file tools must not modify (issue #501): the agent's
567    /// own config, memory, soul and context files, plus the path allowlist.
568    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        // ./SOUL.md in the process directory is the last soul fallback.
596        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        // Invalidate cache when a new tool is added
635        self.cached_static_definitions = OnceLock::new();
636        self.tools.push(tool);
637    }
638
639    /// Register a tool with an explicit effective risk that replaces the
640    /// name-derived `tool_risk` at the policy/approval choke points. Used
641    /// for MCP tools whose server carries a trust tier.
642    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    /// Register every MCP tool from `mcp_manager`, mapping each server's
648    /// trust tier onto the tool's effective risk. Shared by all boot paths
649    /// (runtime, ACP) so trust tiers are honored uniformly.
650    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    /// Effective risk of a tool: registration-time override first, then the
665    /// shared name-derived mapping.
666    pub fn effective_tool_risk(&self, name: &str) -> ToolRisk {
667        self.risk_overrides
668            .get(name)
669            .copied()
670            .unwrap_or_else(|| tool_risk(name))
671    }
672
673    pub fn definitions(&self) -> Vec<ToolDefinition> {
674        let static_defs = self
675            .cached_static_definitions
676            .get_or_init(|| self.tools.iter().map(|t| t.definition()).collect())
677            .clone();
678        let mut out = static_defs;
679        let mut existing: std::collections::HashSet<String> =
680            out.iter().map(|d| d.name.to_ascii_lowercase()).collect();
681        for plugin_def in crate::plugins::dynamic_plugin_tool_definitions(&self.config) {
682            let normalized = plugin_def.name.to_ascii_lowercase();
683            if existing.insert(normalized) {
684                out.push(plugin_def);
685            }
686        }
687        out
688    }
689
690    pub async fn execute(&self, name: &str, input: serde_json::Value) -> ToolResult {
691        for tool in &self.tools {
692            if tool.name() == name {
693                let started = Instant::now();
694                let mut result = tool.execute(input).await;
695                result.duration_ms = Some(started.elapsed().as_millis());
696                result.bytes = result.content.len();
697                if result.is_error && result.error_type.is_none() {
698                    result.error_type = Some("tool_error".to_string());
699                }
700                if result.status_code.is_none() {
701                    result.status_code = Some(if result.is_error { 1 } else { 0 });
702                }
703                return result;
704            }
705        }
706        ToolResult::error(format!("Unknown tool: {name}")).with_error_type("unknown_tool")
707    }
708
709    pub async fn execute_with_auth(
710        &self,
711        name: &str,
712        input: serde_json::Value,
713        auth: &ToolAuthContext,
714    ) -> ToolResult {
715        if auth.caller_channel == "work"
716            && auth.principal.starts_with("subagent:")
717            && !matches!(
718                name,
719                "web_search"
720                    | "web_fetch"
721                    | "deep_research"
722                    | "read_memory"
723                    | "structured_memory_search"
724                    | "session_search"
725                    | "read_file"
726                    | "grep"
727                    | "glob"
728                    | "time_math"
729                    | "get_current_time"
730                    | "compare_time"
731                    | "calculate"
732                    | "osv_check"
733                    | "fetch_artifact"
734                    | "describe_image"
735                    | "consult_specialist"
736                    | "report_progress"
737                    | "subagents_list"
738                    | "subagents_info"
739            )
740        {
741            return ToolResult::error(format!(
742                "Tool `{name}` is not available to Work Subagents; Workspace writes are owned by the Main Agent"
743            ))
744            .with_error_type("work_subagent_read_only");
745        }
746        if auth.principal == "learning_foundry"
747            && !matches!(
748                name,
749                "web_search"
750                    | "web_fetch"
751                    | "deep_research"
752                    | "read_memory"
753                    | "session_search"
754                    | "read_file"
755                    | "grep"
756                    | "glob"
757                    | "time_math"
758                    | "get_current_time"
759                    | "compare_time"
760                    | "calculate"
761            )
762        {
763            return ToolResult::error(format!(
764                "Tool `{name}` is not available to the read-only Learning Foundry principal"
765            ))
766            .with_error_type("learning_foundry_read_only");
767        }
768        // Operator-set tool policy. Enforced here — the single choke point —
769        // so main-loop calls, sub-agent loops, and any future caller are all
770        // covered; a denied tool cannot be reached by delegation. Decisions
771        // are sealed into the audit chain fire-and-forget (the DB write
772        // itself preserves chain order).
773        let effective_risk = self.effective_tool_risk(name);
774        match crate::tool_guardrails::evaluate_tool_policy_for_auth_with_risk(
775            &self.config.tool_policy,
776            name,
777            auth,
778            effective_risk,
779        ) {
780            crate::tool_guardrails::PolicyDecision::Allow => {}
781            decision => {
782                let (action, status, block_reason) = match decision {
783                    crate::tool_guardrails::PolicyDecision::Warn(reason) => {
784                        ("warn", "allowed", Err(reason))
785                    }
786                    crate::tool_guardrails::PolicyDecision::Block(reason) => {
787                        ("block", "blocked", Ok(reason))
788                    }
789                    crate::tool_guardrails::PolicyDecision::Allow => unreachable!(),
790                };
791                let reason = match &block_reason {
792                    Ok(r) | Err(r) => r.clone(),
793                };
794                tracing::warn!(
795                    tool = name,
796                    chat_id = auth.caller_chat_id,
797                    principal = %auth.principal,
798                    action,
799                    %reason,
800                    "Tool policy violation"
801                );
802                if let Some(db) = self.audit_db.clone() {
803                    let tool = name.to_string();
804                    let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
805                    let detail = reason.clone();
806                    tokio::spawn(async move {
807                        let _ = crate::internal::storage::db::call_blocking(db, move |db| {
808                            db.log_audit_event(
809                                "tool_policy",
810                                &actor,
811                                action,
812                                Some(&tool),
813                                status,
814                                Some(&detail),
815                            )
816                        })
817                        .await;
818                    });
819                }
820                if block_reason.is_ok() {
821                    return ToolResult::error(format!(
822                        "Tool call blocked by policy: {reason}. Do not retry this tool; \
823                         use a permitted tool or explain the limitation to the user."
824                    ))
825                    .with_error_type("tool_policy_blocked");
826                }
827            }
828        }
829        for (url, decision) in crate::internal::tool_runtime::egress::evaluate_tool_input(
830            &self.config.egress_policy,
831            &input,
832        ) {
833            let (action, status, reason, blocked) = match decision {
834                crate::internal::tool_runtime::egress::EgressDecision::Allow => continue,
835                crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
836                    ("warn", "allowed", reason, false)
837                }
838                crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
839                    ("block", "blocked", reason, true)
840                }
841            };
842            tracing::warn!(
843                tool = name,
844                chat_id = auth.caller_chat_id,
845                principal = %auth.principal,
846                %url,
847                action,
848                %reason,
849                "Egress policy violation"
850            );
851            if let Some(db) = self.audit_db.clone() {
852                let tool = name.to_string();
853                let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
854                let detail = format!("{reason}; url={url}");
855                tokio::spawn(async move {
856                    let _ = crate::internal::storage::db::call_blocking(db, move |db| {
857                        db.log_audit_event(
858                            "egress_policy",
859                            &actor,
860                            action,
861                            Some(&tool),
862                            status,
863                            Some(&detail),
864                        )
865                    })
866                    .await;
867                });
868            }
869            if blocked {
870                return ToolResult::error(format!(
871                    "Tool call blocked by egress policy: {reason}. Do not retry this destination."
872                ))
873                .with_error_type("egress_policy_blocked");
874            }
875        }
876        if let Err(msg) =
877            validate_execution_policy(name, self.sandbox_mode, self.sandbox_runtime_available)
878        {
879            return ToolResult::error(msg).with_error_type("execution_policy_blocked");
880        }
881        let mut input = input;
882        if self.config.high_risk_tool_user_confirmation_required {
883            // Only tools that can actually pause for approval need the
884            // standing-grant lookup: High-risk tools (registry gate) plus the
885            // tools with their own in-tool gates (`a2a_send` sandboxed peer;
886            // `bash` is already High-risk). Everything else skips the DB read
887            // on the hot path.
888            let gateable = effective_risk == ToolRisk::High || name == "a2a_send";
889            if gateable {
890                // A standing per-chat allowance (operator replied "always
891                // allow" to a previous prompt) satisfies the gate. It never
892                // bypasses tool_policy or egress policy, which already ran
893                // above. When present, inject the approval marker so the
894                // in-tool gates (bash dangerous patterns, sandboxed a2a
895                // peers) honor it too instead of re-prompting every call.
896                let standing = if let Some(db) = self.audit_db.clone() {
897                    let key = format!("approved_tool:{}:{}", auth.caller_chat_id, name);
898                    crate::internal::storage::db::call_blocking(db, move |db| {
899                        db.get_runtime_meta(&key)
900                    })
901                    .await
902                    .ok()
903                    .flatten()
904                    .is_some()
905                } else {
906                    false
907                };
908                if standing {
909                    tracing::info!(
910                        tool = name,
911                        chat_id = auth.caller_chat_id,
912                        "High-risk tool allowed by standing per-chat approval"
913                    );
914                    if let serde_json::Value::Object(map) = &mut input {
915                        map.insert(
916                            "__microclaw_high_risk_approved".to_string(),
917                            serde_json::Value::Bool(true),
918                        );
919                    }
920                } else if let Some(blocked) =
921                    require_high_risk_approval_with_risk(name, effective_risk, auth, &input)
922                {
923                    return blocked;
924                }
925            }
926        }
927
928        tracing::debug!(
929            tool = name,
930            risk = effective_risk.as_str(),
931            execution_policy = tool_execution_policy(name).as_str(),
932            sandbox_mode = ?self.sandbox_mode,
933            sandbox_runtime_available = self.sandbox_runtime_available,
934            "tool execution policy evaluated"
935        );
936        let input = Self::inject_default_chat_id_if_missing(name, input, auth);
937        let input = inject_auth_context(input, auth);
938        let result = self.execute(name, input.clone()).await;
939        if result.error_type.as_deref() == Some("unknown_tool") {
940            if let Some(dynamic) =
941                crate::plugins::execute_dynamic_plugin_tool(&self.config, name, input).await
942            {
943                return dynamic;
944            }
945        }
946        result
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::config::WorkingDirIsolation;
954    use async_trait::async_trait;
955    use serde_json::json;
956
957    #[test]
958    fn test_tool_result_success() {
959        let r = ToolResult::success("ok".into());
960        assert_eq!(r.content, "ok");
961        assert!(!r.is_error);
962    }
963
964    #[test]
965    fn test_tool_result_error() {
966        let r = ToolResult::error("fail".into());
967        assert_eq!(r.content, "fail");
968        assert!(r.is_error);
969    }
970
971    #[test]
972    fn test_schema_object() {
973        let schema = schema_object(
974            json!({
975                "name": {"type": "string"},
976                "age": {"type": "integer"}
977            }),
978            &["name"],
979        );
980        assert_eq!(schema["type"], "object");
981        assert!(schema["properties"]["name"].is_object());
982        assert!(schema["properties"]["age"].is_object());
983        let required = schema["required"].as_array().unwrap();
984        assert_eq!(required.len(), 1);
985        assert_eq!(required[0], "name");
986    }
987
988    #[test]
989    fn test_schema_object_empty_required() {
990        let schema = schema_object(json!({}), &[]);
991        let required = schema["required"].as_array().unwrap();
992        assert!(required.is_empty());
993    }
994
995    #[test]
996    fn test_auth_context_from_input() {
997        let input = json!({
998            "__microclaw_auth": {
999                "caller_channel": "telegram",
1000                "caller_chat_id": 123,
1001                "control_chat_ids": [123, 999]
1002            }
1003        });
1004        let auth = auth_context_from_input(&input).unwrap();
1005        assert_eq!(auth.caller_channel, "telegram");
1006        assert_eq!(auth.caller_chat_id, 123);
1007        assert!(auth.is_control_chat());
1008        assert!(auth.can_access_chat(456));
1009    }
1010
1011    #[test]
1012    fn test_authorize_chat_access_denied() {
1013        let input = json!({
1014            "__microclaw_auth": {
1015                "caller_channel": "telegram",
1016                "caller_chat_id": 100,
1017                "control_chat_ids": []
1018            }
1019        });
1020        let err = authorize_chat_access(&input, 200).unwrap_err();
1021        assert!(err.contains("Permission denied"));
1022    }
1023
1024    #[test]
1025    fn test_resolve_tool_working_dir_shared() {
1026        let dir = resolve_tool_working_dir(
1027            std::path::Path::new("/tmp/work"),
1028            WorkingDirIsolation::Shared,
1029            &json!({
1030                "__microclaw_auth": {
1031                    "caller_channel": "telegram",
1032                    "caller_chat_id": 123,
1033                    "control_chat_ids": []
1034                }
1035            }),
1036        );
1037        assert_eq!(dir, std::path::PathBuf::from("/tmp/work/shared"));
1038    }
1039
1040    #[test]
1041    fn test_resolve_tool_working_dir_direct() {
1042        let dir = resolve_tool_working_dir(
1043            std::path::Path::new("/tmp/work"),
1044            WorkingDirIsolation::Direct,
1045            &json!({
1046                "__microclaw_auth": {
1047                    "caller_channel": "headless",
1048                    "caller_chat_id": 123,
1049                    "control_chat_ids": []
1050                }
1051            }),
1052        );
1053        assert_eq!(dir, std::path::PathBuf::from("/tmp/work"));
1054    }
1055
1056    #[test]
1057    fn test_resolve_tool_working_dir_chat() {
1058        let dir = resolve_tool_working_dir(
1059            std::path::Path::new("/tmp/work"),
1060            WorkingDirIsolation::Chat,
1061            &json!({
1062                "__microclaw_auth": {
1063                    "caller_channel": "discord",
1064                    "caller_chat_id": -100123,
1065                    "control_chat_ids": []
1066                }
1067            }),
1068        );
1069        assert_eq!(
1070            dir,
1071            std::path::PathBuf::from("/tmp/work/chat/discord/neg100123")
1072        );
1073    }
1074
1075    struct DummyTool {
1076        tool_name: String,
1077    }
1078
1079    #[async_trait]
1080    impl Tool for DummyTool {
1081        fn name(&self) -> &str {
1082            &self.tool_name
1083        }
1084
1085        fn definition(&self) -> ToolDefinition {
1086            ToolDefinition {
1087                name: self.tool_name.clone(),
1088                description: "dummy".into(),
1089                input_schema: schema_object(json!({}), &[]),
1090            }
1091        }
1092
1093        async fn execute(&self, _input: serde_json::Value) -> ToolResult {
1094            ToolResult::success("ok".into())
1095        }
1096    }
1097
1098    struct CaptureInputTool {
1099        tool_name: String,
1100    }
1101
1102    #[async_trait]
1103    impl Tool for CaptureInputTool {
1104        fn name(&self) -> &str {
1105            &self.tool_name
1106        }
1107
1108        fn definition(&self) -> ToolDefinition {
1109            ToolDefinition {
1110                name: self.tool_name.clone(),
1111                description: "capture".into(),
1112                input_schema: schema_object(json!({}), &[]),
1113            }
1114        }
1115
1116        async fn execute(&self, input: serde_json::Value) -> ToolResult {
1117            ToolResult::success(input.to_string())
1118        }
1119    }
1120
1121    #[test]
1122    fn test_tool_risk_levels() {
1123        assert_eq!(tool_risk("bash"), ToolRisk::High);
1124        assert_eq!(tool_risk("write_file"), ToolRisk::Medium);
1125        assert_eq!(tool_risk("pause_scheduled_task"), ToolRisk::Medium);
1126        assert_eq!(tool_risk("sync_skills"), ToolRisk::Medium);
1127        assert_eq!(tool_risk("read_file"), ToolRisk::Low);
1128    }
1129
1130    #[tokio::test]
1131    async fn test_tool_policy_blocks_at_choke_point() {
1132        // Policy enforcement lives in execute_with_auth so EVERY caller —
1133        // main loop, sub-agent loops — is covered. Denied tools return a
1134        // typed error before any execution-policy/approval logic runs.
1135        let mut config = crate::config::Config::test_defaults();
1136        config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
1137        config.tool_policy.deny_tools = vec!["bash".to_string()];
1138        let registry = ToolRegistry {
1139            config,
1140            sandbox_mode: SandboxMode::Off,
1141            sandbox_runtime_available: false,
1142            cached_static_definitions: OnceLock::new(),
1143            audit_db: None,
1144            risk_overrides: std::collections::HashMap::new(),
1145            tools: vec![Box::new(DummyTool {
1146                tool_name: "bash".into(),
1147            })],
1148        };
1149        let auth = ToolAuthContext {
1150            caller_channel: "telegram".into(),
1151            caller_chat_id: 1,
1152            principal: "main".into(),
1153            control_chat_ids: vec![],
1154            env_files: vec![],
1155        };
1156        let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1157        assert!(result.is_error);
1158        assert_eq!(result.error_type.as_deref(), Some("tool_policy_blocked"));
1159
1160        // Warn mode executes the tool (DummyTool returns success).
1161        let mut warn_config = crate::config::Config::test_defaults();
1162        warn_config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Warn;
1163        warn_config.tool_policy.deny_tools = vec!["bash".to_string()];
1164        warn_config.high_risk_tool_user_confirmation_required = false;
1165        let registry = ToolRegistry {
1166            config: warn_config,
1167            sandbox_mode: SandboxMode::Off,
1168            sandbox_runtime_available: false,
1169            cached_static_definitions: OnceLock::new(),
1170            audit_db: None,
1171            risk_overrides: std::collections::HashMap::new(),
1172            tools: vec![Box::new(DummyTool {
1173                tool_name: "bash".into(),
1174            })],
1175        };
1176        let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1177        assert!(
1178            !result.is_error,
1179            "warn mode must still execute: {}",
1180            result.content
1181        );
1182    }
1183
1184    #[tokio::test]
1185    async fn learning_foundry_principal_is_read_only_at_choke_point() {
1186        let registry = ToolRegistry {
1187            config: crate::config::Config::test_defaults(),
1188            sandbox_mode: SandboxMode::Off,
1189            sandbox_runtime_available: false,
1190            cached_static_definitions: OnceLock::new(),
1191            audit_db: None,
1192            risk_overrides: std::collections::HashMap::new(),
1193            tools: vec![
1194                Box::new(DummyTool {
1195                    tool_name: "write_file".into(),
1196                }),
1197                Box::new(DummyTool {
1198                    tool_name: "web_search".into(),
1199                }),
1200            ],
1201        };
1202        let auth = ToolAuthContext {
1203            caller_channel: "learning_foundry".into(),
1204            caller_chat_id: -7,
1205            principal: "learning_foundry".into(),
1206            control_chat_ids: vec![],
1207            env_files: vec![],
1208        };
1209        let write = registry
1210            .execute_with_auth("write_file", json!({}), &auth)
1211            .await;
1212        assert!(write.is_error);
1213        assert_eq!(
1214            write.error_type.as_deref(),
1215            Some("learning_foundry_read_only")
1216        );
1217        let search = registry
1218            .execute_with_auth("web_search", json!({}), &auth)
1219            .await;
1220        assert!(!search.is_error);
1221    }
1222
1223    #[tokio::test]
1224    async fn work_subagent_is_read_only_but_main_agent_can_write() {
1225        let make_registry = || ToolRegistry {
1226            config: crate::config::Config::test_defaults(),
1227            sandbox_mode: SandboxMode::Off,
1228            sandbox_runtime_available: false,
1229            cached_static_definitions: OnceLock::new(),
1230            audit_db: None,
1231            risk_overrides: std::collections::HashMap::new(),
1232            tools: vec![
1233                Box::new(DummyTool {
1234                    tool_name: "write_file".into(),
1235                }),
1236                Box::new(DummyTool {
1237                    tool_name: "read_file".into(),
1238                }),
1239            ],
1240        };
1241        let mut auth = ToolAuthContext {
1242            caller_channel: "work".into(),
1243            caller_chat_id: 1,
1244            principal: "subagent:run-1".into(),
1245            control_chat_ids: vec![],
1246            env_files: vec![],
1247        };
1248
1249        let denied = make_registry()
1250            .execute_with_auth("write_file", json!({}), &auth)
1251            .await;
1252        assert!(denied.is_error);
1253        assert_eq!(
1254            denied.error_type.as_deref(),
1255            Some("work_subagent_read_only")
1256        );
1257        let read = make_registry()
1258            .execute_with_auth("read_file", json!({}), &auth)
1259            .await;
1260        assert!(!read.is_error);
1261
1262        auth.principal = "main".into();
1263        let write = make_registry()
1264            .execute_with_auth("write_file", json!({}), &auth)
1265            .await;
1266        assert!(!write.is_error);
1267    }
1268
1269    #[tokio::test]
1270    async fn test_high_risk_tool_requires_explicit_approval_on_web() {
1271        let registry = ToolRegistry {
1272            config: crate::config::Config::test_defaults(),
1273            sandbox_mode: SandboxMode::Off,
1274            sandbox_runtime_available: false,
1275            cached_static_definitions: OnceLock::new(),
1276            audit_db: None,
1277            risk_overrides: std::collections::HashMap::new(),
1278            tools: vec![Box::new(DummyTool {
1279                tool_name: "bash".into(),
1280            })],
1281        };
1282        let auth = ToolAuthContext {
1283            caller_channel: "web".into(),
1284            caller_chat_id: 1,
1285            principal: "main".into(),
1286            control_chat_ids: vec![],
1287            env_files: vec![],
1288        };
1289
1290        let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1291        assert!(first.is_error);
1292        assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1293
1294        let second = registry.execute_with_auth("bash", json!({}), &auth).await;
1295        assert!(second.is_error);
1296        assert_eq!(second.error_type.as_deref(), Some("approval_required"));
1297
1298        let approved = registry
1299            .execute_with_auth(
1300                "bash",
1301                json!({"__microclaw_high_risk_approved": true}),
1302                &auth,
1303            )
1304            .await;
1305        assert!(!approved.is_error);
1306        assert_eq!(approved.content, "ok");
1307    }
1308
1309    #[tokio::test]
1310    async fn test_high_risk_tool_requires_explicit_approval_on_control_chat() {
1311        let registry = ToolRegistry {
1312            config: crate::config::Config::test_defaults(),
1313            sandbox_mode: SandboxMode::Off,
1314            sandbox_runtime_available: false,
1315            cached_static_definitions: OnceLock::new(),
1316            audit_db: None,
1317            risk_overrides: std::collections::HashMap::new(),
1318            tools: vec![Box::new(DummyTool {
1319                tool_name: "bash".into(),
1320            })],
1321        };
1322        let auth = ToolAuthContext {
1323            caller_channel: "telegram".into(),
1324            caller_chat_id: 123,
1325            principal: "main".into(),
1326            control_chat_ids: vec![123],
1327            env_files: vec![],
1328        };
1329
1330        let first = registry.execute_with_auth("bash", json!({}), &auth).await;
1331        assert!(first.is_error);
1332        assert_eq!(first.error_type.as_deref(), Some("approval_required"));
1333
1334        let approved = registry
1335            .execute_with_auth(
1336                "bash",
1337                json!({"__microclaw_high_risk_approved": true}),
1338                &auth,
1339            )
1340            .await;
1341        assert!(!approved.is_error);
1342        assert_eq!(approved.content, "ok");
1343    }
1344
1345    #[tokio::test]
1346    async fn test_high_risk_tool_confirmation_flag_false_hard_disables_web_approval_gate() {
1347        let mut config = crate::config::Config::test_defaults();
1348        config.high_risk_tool_user_confirmation_required = false;
1349        let registry = ToolRegistry {
1350            config,
1351            sandbox_mode: SandboxMode::Off,
1352            sandbox_runtime_available: false,
1353            cached_static_definitions: OnceLock::new(),
1354            audit_db: None,
1355            risk_overrides: std::collections::HashMap::new(),
1356            tools: vec![Box::new(DummyTool {
1357                tool_name: "bash".into(),
1358            })],
1359        };
1360        let auth = ToolAuthContext {
1361            caller_channel: "web".into(),
1362            caller_chat_id: 1,
1363            principal: "main".into(),
1364            control_chat_ids: vec![],
1365            env_files: vec![],
1366        };
1367
1368        let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1369        assert!(!result.is_error);
1370        assert_eq!(result.content, "ok");
1371    }
1372
1373    #[tokio::test]
1374    async fn test_high_risk_tool_confirmation_flag_false_hard_disables_control_chat_approval_gate()
1375    {
1376        let mut config = crate::config::Config::test_defaults();
1377        config.high_risk_tool_user_confirmation_required = false;
1378        let registry = ToolRegistry {
1379            config,
1380            sandbox_mode: SandboxMode::Off,
1381            sandbox_runtime_available: false,
1382            cached_static_definitions: OnceLock::new(),
1383            audit_db: None,
1384            risk_overrides: std::collections::HashMap::new(),
1385            tools: vec![Box::new(DummyTool {
1386                tool_name: "bash".into(),
1387            })],
1388        };
1389        let auth = ToolAuthContext {
1390            caller_channel: "telegram".into(),
1391            caller_chat_id: 123,
1392            principal: "main".into(),
1393            control_chat_ids: vec![123],
1394            env_files: vec![],
1395        };
1396
1397        let result = registry.execute_with_auth("bash", json!({}), &auth).await;
1398        assert!(!result.is_error);
1399        assert_eq!(result.content, "ok");
1400    }
1401
1402    #[tokio::test]
1403    async fn test_medium_risk_tool_no_second_approval() {
1404        let registry = ToolRegistry {
1405            config: crate::config::Config::test_defaults(),
1406            sandbox_mode: SandboxMode::Off,
1407            sandbox_runtime_available: false,
1408            cached_static_definitions: OnceLock::new(),
1409            audit_db: None,
1410            risk_overrides: std::collections::HashMap::new(),
1411            tools: vec![Box::new(DummyTool {
1412                tool_name: "write_file".into(),
1413            })],
1414        };
1415        let auth = ToolAuthContext {
1416            caller_channel: "web".into(),
1417            caller_chat_id: 1,
1418            principal: "main".into(),
1419            control_chat_ids: vec![],
1420            env_files: vec![],
1421        };
1422
1423        let result = registry
1424            .execute_with_auth("write_file", json!({}), &auth)
1425            .await;
1426        assert!(!result.is_error);
1427        assert_eq!(result.content, "ok");
1428    }
1429
1430    #[tokio::test]
1431    async fn test_dynamic_plugin_tool_executes_without_restart() {
1432        let root = std::env::temp_dir().join(format!("microclaw_plugin_{}", uuid::Uuid::new_v4()));
1433        let plugins_dir = root.join("plugins");
1434        std::fs::create_dir_all(&plugins_dir).unwrap();
1435        let manifest = plugins_dir.join("demo.yaml");
1436        std::fs::write(
1437            &manifest,
1438            r#"
1439name: demo
1440enabled: true
1441tools:
1442  - name: plugin_runtime_echo
1443    description: runtime echo
1444    input_schema:
1445      type: object
1446      properties: {}
1447      required: []
1448    run:
1449      command: "echo plugin-ok"
1450      timeout_secs: 5
1451"#,
1452        )
1453        .unwrap();
1454
1455        let mut config = crate::config::Config::test_defaults();
1456        config.working_dir = root.join("work").to_string_lossy().to_string();
1457        config.plugins.enabled = true;
1458        config.plugins.dir = Some(plugins_dir.to_string_lossy().to_string());
1459
1460        let registry = ToolRegistry {
1461            config,
1462            tools: vec![],
1463            sandbox_mode: SandboxMode::Off,
1464            sandbox_runtime_available: false,
1465            cached_static_definitions: OnceLock::new(),
1466            audit_db: None,
1467            risk_overrides: std::collections::HashMap::new(),
1468        };
1469        let auth = ToolAuthContext {
1470            caller_channel: "web".into(),
1471            caller_chat_id: 7,
1472            principal: "main".into(),
1473            control_chat_ids: vec![],
1474            env_files: vec![],
1475        };
1476
1477        let defs = registry.definitions();
1478        assert!(defs.iter().any(|d| d.name == "plugin_runtime_echo"));
1479
1480        let result = registry
1481            .execute_with_auth("plugin_runtime_echo", json!({}), &auth)
1482            .await;
1483        assert!(!result.is_error, "{}", result.content);
1484        assert!(result.content.contains("plugin-ok"));
1485
1486        let _ = std::fs::remove_dir_all(root);
1487    }
1488
1489    #[tokio::test]
1490    async fn test_injects_default_chat_id_for_memory_tools() {
1491        let registry = ToolRegistry {
1492            config: crate::config::Config::test_defaults(),
1493            sandbox_mode: SandboxMode::Off,
1494            sandbox_runtime_available: false,
1495            cached_static_definitions: OnceLock::new(),
1496            audit_db: None,
1497            risk_overrides: std::collections::HashMap::new(),
1498            tools: vec![Box::new(CaptureInputTool {
1499                tool_name: "write_memory".into(),
1500            })],
1501        };
1502        let auth = ToolAuthContext {
1503            caller_channel: "feishu".into(),
1504            caller_chat_id: 8009499081,
1505            principal: "main".into(),
1506            control_chat_ids: vec![],
1507            env_files: vec![],
1508        };
1509
1510        let result = registry
1511            .execute_with_auth(
1512                "write_memory",
1513                json!({"scope":"chat","content":"hello"}),
1514                &auth,
1515            )
1516            .await;
1517        assert!(!result.is_error);
1518        let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1519        assert_eq!(payload["chat_id"].as_i64(), Some(8009499081));
1520    }
1521
1522    #[tokio::test]
1523    async fn test_does_not_override_existing_chat_id() {
1524        let registry = ToolRegistry {
1525            config: crate::config::Config::test_defaults(),
1526            sandbox_mode: SandboxMode::Off,
1527            sandbox_runtime_available: false,
1528            cached_static_definitions: OnceLock::new(),
1529            audit_db: None,
1530            risk_overrides: std::collections::HashMap::new(),
1531            tools: vec![Box::new(CaptureInputTool {
1532                tool_name: "write_memory".into(),
1533            })],
1534        };
1535        let auth = ToolAuthContext {
1536            caller_channel: "feishu".into(),
1537            caller_chat_id: 8009499081,
1538            principal: "main".into(),
1539            control_chat_ids: vec![],
1540            env_files: vec![],
1541        };
1542
1543        let result = registry
1544            .execute_with_auth(
1545                "write_memory",
1546                json!({"scope":"chat","chat_id":42,"content":"hello"}),
1547                &auth,
1548            )
1549            .await;
1550        assert!(!result.is_error);
1551        let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1552        assert_eq!(payload["chat_id"].as_i64(), Some(42));
1553    }
1554
1555    #[tokio::test]
1556    async fn test_injects_default_chat_id_for_send_message_tool() {
1557        let registry = ToolRegistry {
1558            config: crate::config::Config::test_defaults(),
1559            sandbox_mode: SandboxMode::Off,
1560            sandbox_runtime_available: false,
1561            cached_static_definitions: OnceLock::new(),
1562            audit_db: None,
1563            risk_overrides: std::collections::HashMap::new(),
1564            tools: vec![Box::new(CaptureInputTool {
1565                tool_name: "send_message".into(),
1566            })],
1567        };
1568        let auth = ToolAuthContext {
1569            caller_channel: "web".into(),
1570            caller_chat_id: 9001,
1571            principal: "main".into(),
1572            control_chat_ids: vec![],
1573            env_files: vec![],
1574        };
1575
1576        let result = registry
1577            .execute_with_auth("send_message", json!({"text":"progress update"}), &auth)
1578            .await;
1579        assert!(!result.is_error);
1580        let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
1581        assert_eq!(payload["chat_id"].as_i64(), Some(9001));
1582        assert_eq!(payload["text"].as_str(), Some("progress update"));
1583    }
1584}