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