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