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