pub mod a2a;
pub mod activate_skill;
pub mod bash;
pub mod browser;
pub mod clarify;
pub mod consult_specialist;
pub mod deep_research;
pub mod describe_image;
pub mod edit_file;
pub mod export_chat;
pub mod fetch_artifact;
pub mod fuzzy_match;
pub mod generate_image;
pub mod generate_podcast;
pub mod glob;
pub mod grep;
pub mod insights;
pub mod knowledge_graph;
pub mod learning_tracks;
pub mod mcp;
pub mod memory;
pub mod osv_check;
pub mod read_file;
#[cfg(feature = "document-tools")]
pub mod render_pdf;
pub mod report_progress;
pub mod schedule;
pub mod send_message;
pub mod session_search;
pub mod skill_manage;
pub mod specialists;
pub mod structured_memory;
pub mod subagents;
pub mod sync_skills;
pub mod text_to_speech;
pub mod time_math;
pub mod todo;
pub mod transcribe_audio;
pub mod web_fetch;
pub mod web_search;
pub mod write_file;
use std::sync::{Arc, OnceLock};
use std::{path::PathBuf, time::Instant};
pub const IDEMPOTENT_TOOLS: &[&str] = &[
"deep_research",
"describe_image",
"export_chat",
"fetch_artifact",
"glob",
"grep",
"insights",
"osv_check",
"read_file",
"session_search",
"time_math",
"transcribe_audio",
"web_fetch",
"web_search",
];
use crate::config::Config;
use crate::internal::channels::channel_adapter::ChannelRegistry;
use crate::internal::storage::db::Database;
use crate::internal::tool_runtime::path_guard::{self, GovernanceGuard};
pub use crate::internal::tool_runtime::runtime::{
auth_context_from_input, authorize_chat_access, resolve_tool_path, resolve_tool_working_dir,
schema_object, tool_execution_policy, tool_risk, validate_execution_policy, Tool,
ToolAuthContext, ToolResult, ToolRisk,
};
use crate::internal::tool_runtime::runtime::{
inject_auth_context, require_high_risk_approval_with_risk,
};
use crate::internal::tool_runtime::sandbox::{ExtraMount, SandboxMode, SandboxRouter};
use crate::memory_backend::MemoryBackend;
use microclaw_core::llm_types::ToolDefinition;
pub struct ToolRegistry {
config: Config,
tools: Vec<Box<dyn Tool>>,
sandbox_mode: SandboxMode,
sandbox_runtime_available: bool,
cached_static_definitions: OnceLock<Vec<ToolDefinition>>,
audit_db: Option<Arc<Database>>,
risk_overrides: std::collections::HashMap<String, ToolRisk>,
}
impl ToolRegistry {
fn should_inject_default_chat_id(tool_name: &str) -> bool {
matches!(
tool_name,
"write_memory"
| "read_memory"
| "todo_read"
| "todo_write"
| "send_message"
| "sessions_spawn"
| "subagents_list"
| "subagents_info"
| "subagents_kill"
| "subagents_focus"
| "subagents_unfocus"
| "subagents_focused"
| "subagents_send"
| "subagents_orchestrate"
| "session_search"
| "learning_tracks"
)
}
fn inject_default_chat_id_if_missing(
tool_name: &str,
input: serde_json::Value,
auth: &ToolAuthContext,
) -> serde_json::Value {
if !Self::should_inject_default_chat_id(tool_name) {
return input;
}
let mut obj = match input {
serde_json::Value::Object(map) => map,
_ => serde_json::Map::new(),
};
let missing_chat_id = obj.get("chat_id").and_then(|v| v.as_i64()).is_none();
if missing_chat_id {
obj.insert(
"chat_id".to_string(),
serde_json::Value::Number(auth.caller_chat_id.into()),
);
}
serde_json::Value::Object(obj)
}
pub fn new(
config: &Config,
channel_registry: Arc<ChannelRegistry>,
db: Arc<Database>,
memory_backend: Arc<MemoryBackend>,
) -> Self {
let audit_db = Some(db.clone());
let working_dir = PathBuf::from(&config.working_dir);
if let Err(e) = std::fs::create_dir_all(&working_dir) {
tracing::warn!(
"Failed to create working_dir '{}': {}",
working_dir.display(),
e
);
}
let skills_data_dir = config.skills_data_dir();
let sandbox_router = Arc::new(SandboxRouter::new(
config.sandbox.clone(),
&working_dir,
Self::build_extra_mounts(&working_dir, &skills_data_dir),
));
tracing::info!(
mode = ?sandbox_router.mode(),
backend = sandbox_router.backend_name(),
"Sandbox initialized"
);
let mut tools: Vec<Box<dyn Tool>> = vec![
Box::new(
bash::BashTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_default_timeout_secs(config.tool_timeout_secs("bash", 120))
.with_sandbox_router(sandbox_router.clone())
.with_dangerous_patterns(&config.bash_dangerous_patterns)
.with_rtk(&config.rtk),
),
Box::new(
browser::BrowserTool::new(&config.data_dir)
.with_default_timeout_secs(config.tool_timeout_secs("browser", 30)),
),
Box::new(read_file::ReadFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(
write_file::WriteFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_diff_max_lines(config.diff_max_lines)
.with_governance_guard(Self::governance_guard(config)),
),
Box::new(
edit_file::EditFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_diff_max_lines(config.diff_max_lines)
.with_governance_guard(Self::governance_guard(config)),
),
Box::new(glob::GlobTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(grep::GrepTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(memory::ReadMemoryTool::new(&config.data_dir, db.clone())),
Box::new(memory::WriteMemoryTool::new(
&config.data_dir,
db.clone(),
memory_backend.clone(),
)),
Box::new(web_fetch::WebFetchTool::new(
config.tool_timeout_secs("web_fetch", 15),
config.web_fetch_validation,
config.web_fetch_url_validation.clone(),
)),
Box::new(
web_search::WebSearchTool::new(config.tool_timeout_secs("web_search", 15))
.with_provider(config.web_search.clone()),
),
Box::new(deep_research::DeepResearchTool::new(
config.tool_timeout_secs("deep_research", 20),
config.web_search.clone(),
config.web_fetch_validation,
config.web_fetch_url_validation.clone(),
)),
Box::new(time_math::GetCurrentTimeTool::new(config.timezone.clone())),
Box::new(time_math::CompareTimeTool::new(config.timezone.clone())),
Box::new(time_math::CalculateTool::new()),
Box::new(send_message::SendMessageTool::new(
channel_registry.clone(),
db.clone(),
if config.bot_username.trim().is_empty() {
"bot".to_string()
} else {
config.bot_username.clone()
},
config.bot_username_overrides(),
)),
Box::new(a2a::A2AListPeersTool::new(config)),
Box::new(a2a::A2ASendTool::new(config)),
Box::new(schedule::ScheduleTaskTool::new(
channel_registry.clone(),
db.clone(),
config.timezone.clone(),
)),
Box::new(schedule::ListTasksTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::PauseTaskTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::ResumeTaskTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::CancelTaskTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::GetTaskHistoryTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::ListTaskDlqTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(schedule::ReplayTaskDlqTool::new(
channel_registry.clone(),
db.clone(),
)),
Box::new(learning_tracks::LearningTracksTool::new(
db.clone(),
config.timezone.clone(),
)),
Box::new(export_chat::ExportChatTool::new(
db.clone(),
&config.data_dir,
)),
Box::new(subagents::SessionsSpawnTool::new(
config,
db.clone(),
channel_registry.clone(),
)),
Box::new(subagents::SubagentsListTool::new(db.clone())),
Box::new(subagents::SubagentsInfoTool::new(db.clone())),
Box::new(subagents::SubagentsKillTool::new(config, db.clone())),
Box::new(subagents::SubagentsFocusTool::new(db.clone())),
Box::new(subagents::SubagentsUnfocusTool::new(db.clone())),
Box::new(subagents::SubagentsFocusedTool::new(db.clone())),
Box::new(subagents::SubagentsSendTool::new(
config,
db.clone(),
channel_registry.clone(),
)),
Box::new(subagents::SubagentsOrchestrateTool::new(
config,
db.clone(),
channel_registry.clone(),
)),
Box::new(subagents::SubagentsLogTool::new(db.clone())),
Box::new(subagents::SubagentsRetryAnnouncesTool::new(
config,
db.clone(),
channel_registry.clone(),
)),
Box::new(
activate_skill::ActivateSkillTool::new_with_runtime(
&skills_data_dir,
&config.data_dir,
)
.with_config_verification(config)
.with_db(db.clone()),
),
Box::new(
skill_manage::SkillManageTool::new(
&skills_data_dir,
config.control_chat_ids.clone(),
)
.with_db(db.clone()),
),
Box::new(sync_skills::SyncSkillsTool::new(&skills_data_dir)),
Box::new(todo::TodoReadTool::new(&config.data_dir)),
Box::new(todo::TodoWriteTool::new(&config.data_dir).with_db(db.clone())),
Box::new(structured_memory::StructuredMemorySearchTool::new(
db.clone(),
memory_backend.clone(),
)),
Box::new(structured_memory::StructuredMemoryDeleteTool::new(
db.clone(),
memory_backend.clone(),
)),
Box::new(structured_memory::StructuredMemoryUpdateTool::new(
db.clone(),
memory_backend.clone(),
)),
Box::new(knowledge_graph::KnowledgeGraphQueryTool::new(db.clone())),
Box::new(knowledge_graph::KnowledgeGraphAddTool::new(db.clone())),
Box::new(session_search::SessionSearchTool::new(db.clone())),
Box::new(
osv_check::OsvCheckTool::new(config.tool_timeout_secs("osv_check", 10))
.with_cache(db.clone()),
),
Box::new(clarify::ClarifyTool::new(
channel_registry.clone(),
db.clone(),
if config.bot_username.trim().is_empty() {
"bot".to_string()
} else {
config.bot_username.clone()
},
config.bot_username_overrides(),
)),
Box::new(generate_image::GenerateImageTool::new(
config,
channel_registry.clone(),
db.clone(),
)),
Box::new(describe_image::DescribeImageTool::new(config)),
Box::new(text_to_speech::TextToSpeechTool::new(
config,
channel_registry.clone(),
db.clone(),
)),
Box::new(generate_podcast::GeneratePodcastTool::new(
config,
channel_registry.clone(),
db.clone(),
)),
Box::new(transcribe_audio::TranscribeAudioTool::new(config)),
Box::new(insights::InsightsTool::new(db.clone())),
Box::new(fetch_artifact::FetchArtifactTool::new(db.clone())),
];
#[cfg(feature = "document-tools")]
tools.push(Box::new(render_pdf::RenderPdfTool::new(
config,
channel_registry.clone(),
db.clone(),
)));
if config.clawhub.agent_tools_enabled {
tools.push(Box::new(crate::clawhub::tools::ClawHubSearchTool::new(
config,
)));
tools.push(Box::new(crate::clawhub::tools::ClawHubInstallTool::new(
config,
)));
}
ToolRegistry {
config: config.clone(),
tools,
sandbox_mode: sandbox_router.mode(),
sandbox_runtime_available: sandbox_router.runtime_available(),
cached_static_definitions: OnceLock::new(),
audit_db,
risk_overrides: std::collections::HashMap::new(),
}
}
pub fn new_sub_agent(
config: &Config,
db: Arc<Database>,
channel_registry: Option<Arc<ChannelRegistry>>,
allow_session_tools: bool,
) -> Self {
let audit_db = Some(db.clone());
let working_dir = PathBuf::from(&config.working_dir);
if let Err(e) = std::fs::create_dir_all(&working_dir) {
tracing::warn!(
"Failed to create working_dir '{}': {}",
working_dir.display(),
e
);
}
let skills_data_dir = config.skills_data_dir();
let sandbox_router = Arc::new(SandboxRouter::new(
config.sandbox.clone(),
&working_dir,
Self::build_extra_mounts(&working_dir, &skills_data_dir),
));
let memory_backend = Arc::new(MemoryBackend::local_only(db.clone()));
let mut tools: Vec<Box<dyn Tool>> = vec![
Box::new(
bash::BashTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_default_timeout_secs(config.tool_timeout_secs("bash", 120))
.with_sandbox_router(sandbox_router.clone())
.with_dangerous_patterns(&config.bash_dangerous_patterns)
.with_rtk(&config.rtk),
),
Box::new(
browser::BrowserTool::new(&config.data_dir)
.with_default_timeout_secs(config.tool_timeout_secs("browser", 30)),
),
Box::new(read_file::ReadFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(
write_file::WriteFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_diff_max_lines(config.diff_max_lines)
.with_governance_guard(Self::governance_guard(config)),
),
Box::new(
edit_file::EditFileTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)
.with_diff_max_lines(config.diff_max_lines)
.with_governance_guard(Self::governance_guard(config)),
),
Box::new(glob::GlobTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(grep::GrepTool::new_with_isolation(
&config.working_dir,
config.working_dir_isolation,
)),
Box::new(memory::ReadMemoryTool::new(&config.data_dir, db.clone())),
Box::new(web_fetch::WebFetchTool::new(
config.tool_timeout_secs("web_fetch", 15),
config.web_fetch_validation,
config.web_fetch_url_validation.clone(),
)),
Box::new(
web_search::WebSearchTool::new(config.tool_timeout_secs("web_search", 15))
.with_provider(config.web_search.clone()),
),
Box::new(deep_research::DeepResearchTool::new(
config.tool_timeout_secs("deep_research", 20),
config.web_search.clone(),
config.web_fetch_validation,
config.web_fetch_url_validation.clone(),
)),
Box::new(time_math::GetCurrentTimeTool::new(config.timezone.clone())),
Box::new(time_math::CompareTimeTool::new(config.timezone.clone())),
Box::new(time_math::CalculateTool::new()),
Box::new(
activate_skill::ActivateSkillTool::new_with_runtime(
&skills_data_dir,
&config.data_dir,
)
.with_config_verification(config)
.with_db(db.clone()),
),
Box::new(structured_memory::StructuredMemorySearchTool::new(
db.clone(),
memory_backend,
)),
Box::new(session_search::SessionSearchTool::new(db.clone())),
Box::new(
osv_check::OsvCheckTool::new(config.tool_timeout_secs("osv_check", 10))
.with_cache(db.clone()),
),
Box::new(fetch_artifact::FetchArtifactTool::new(db.clone())),
Box::new(describe_image::DescribeImageTool::new(config)),
Box::new(consult_specialist::ConsultSpecialistTool::new(config)),
];
if let Some(cr) = &channel_registry {
tools.push(Box::new(generate_image::GenerateImageTool::new(
config,
cr.clone(),
db.clone(),
)));
tools.push(Box::new(report_progress::ReportProgressTool::new(
config,
cr.clone(),
db.clone(),
)));
}
if allow_session_tools {
if let Some(channel_registry) = channel_registry {
tools.push(Box::new(subagents::SessionsSpawnTool::new(
config,
db.clone(),
channel_registry.clone(),
)));
tools.push(Box::new(subagents::SubagentsListTool::new(db.clone())));
tools.push(Box::new(subagents::SubagentsInfoTool::new(db.clone())));
tools.push(Box::new(subagents::SubagentsKillTool::new(
config,
db.clone(),
)));
tools.push(Box::new(subagents::SubagentsOrchestrateTool::new(
config,
db.clone(),
channel_registry.clone(),
)));
tools.push(Box::new(subagents::SubagentsLogTool::new(db.clone())));
}
}
ToolRegistry {
config: config.clone(),
tools,
sandbox_mode: sandbox_router.mode(),
sandbox_runtime_available: sandbox_router.runtime_available(),
cached_static_definitions: OnceLock::new(),
audit_db,
risk_overrides: std::collections::HashMap::new(),
}
}
fn governance_guard(config: &Config) -> GovernanceGuard {
if config.allow_governance_file_writes {
return GovernanceGuard::default();
}
let data_root = config.data_root_dir();
let souls_dir = PathBuf::from(config.souls_data_dir());
let mut guard = GovernanceGuard::default()
.protect_data_root(&data_root)
.protect_data_root(config.runtime_data_dir())
.exempt(&config.working_dir)
.exempt(config.skills_data_dir())
.protect_dir(&souls_dir)
.protect_dir(match &config.context_dir {
Some(dir) => PathBuf::from(shellexpand::tilde(dir).into_owned()),
None => data_root.join("context"),
});
if data_root.file_name().and_then(|n| n.to_str()) == Some("runtime") {
if let Some(parent) = data_root.parent() {
guard = guard.protect_data_root(parent);
}
}
if let Ok(Some(config_path)) = Config::resolve_config_path() {
guard = guard.protect_file(config_path);
}
if let Some(allowlist) = path_guard::path_allowlist_file() {
guard = guard.protect_file(allowlist);
}
guard = guard.protect_file("SOUL.md");
let mut soul_paths: Vec<String> = config.soul_path.iter().cloned().collect();
for (name, value) in &config.channels {
soul_paths.extend(config.soul_path_for_channel(name));
if let Some(accounts) = value.get("accounts").and_then(|v| v.as_mapping()) {
for account in accounts.keys().filter_map(|k| k.as_str()) {
soul_paths.extend(config.soul_path_for_channel(&format!("{name}.{account}")));
}
}
}
for soul in soul_paths {
let configured = PathBuf::from(shellexpand::tilde(&soul).into_owned());
if configured.is_relative() {
guard = guard
.protect_file(data_root.join(&configured))
.protect_file(souls_dir.join(&configured));
}
guard = guard.protect_file(configured);
}
guard
}
fn build_extra_mounts(working_dir: &PathBuf, skills_data_dir: &str) -> Vec<ExtraMount> {
let skills_path = PathBuf::from(skills_data_dir);
let canonical_skills = std::fs::canonicalize(&skills_path).unwrap_or(skills_path.clone());
let canonical_working =
std::fs::canonicalize(working_dir).unwrap_or_else(|_| working_dir.clone());
let mut mounts = Vec::new();
if canonical_skills.exists() && canonical_skills != canonical_working {
mounts.push(ExtraMount {
host_path: canonical_skills,
read_only: true,
});
}
mounts
}
pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
self.cached_static_definitions = OnceLock::new();
self.tools.push(tool);
}
pub fn add_tool_with_risk(&mut self, tool: Box<dyn Tool>, risk: ToolRisk) {
self.risk_overrides.insert(tool.name().to_string(), risk);
self.add_tool(tool);
}
pub fn register_mcp_tools(&mut self, mcp_manager: &crate::mcp::McpManager) {
for (server, tool_info) in mcp_manager.all_tools() {
let risk = match server.trust() {
crate::mcp::McpTrust::Trusted => ToolRisk::Low,
crate::mcp::McpTrust::Limited => ToolRisk::Medium,
crate::mcp::McpTrust::Sandboxed => ToolRisk::High,
};
self.add_tool_with_risk(
Box::new(crate::tools::mcp::McpTool::new(server, tool_info)),
risk,
);
}
}
pub fn effective_tool_risk(&self, name: &str) -> ToolRisk {
self.risk_overrides
.get(name)
.copied()
.unwrap_or_else(|| tool_risk(name))
}
pub fn definitions(&self) -> Vec<ToolDefinition> {
let static_defs = self
.cached_static_definitions
.get_or_init(|| self.tools.iter().map(|t| t.definition()).collect())
.clone();
let mut out = static_defs;
let mut existing: std::collections::HashSet<String> =
out.iter().map(|d| d.name.to_ascii_lowercase()).collect();
for plugin_def in crate::plugins::dynamic_plugin_tool_definitions(&self.config) {
let normalized = plugin_def.name.to_ascii_lowercase();
if existing.insert(normalized) {
out.push(plugin_def);
}
}
out
}
pub async fn execute(&self, name: &str, input: serde_json::Value) -> ToolResult {
for tool in &self.tools {
if tool.name() == name {
let started = Instant::now();
let mut result = tool.execute(input).await;
result.duration_ms = Some(started.elapsed().as_millis());
result.bytes = result.content.len();
if result.is_error && result.error_type.is_none() {
result.error_type = Some("tool_error".to_string());
}
if result.status_code.is_none() {
result.status_code = Some(if result.is_error { 1 } else { 0 });
}
return result;
}
}
ToolResult::error(format!("Unknown tool: {name}")).with_error_type("unknown_tool")
}
pub async fn execute_with_auth(
&self,
name: &str,
input: serde_json::Value,
auth: &ToolAuthContext,
) -> ToolResult {
if auth.caller_channel == "work"
&& auth.principal.starts_with("subagent:")
&& !matches!(
name,
"web_search"
| "web_fetch"
| "deep_research"
| "read_memory"
| "structured_memory_search"
| "session_search"
| "read_file"
| "grep"
| "glob"
| "time_math"
| "get_current_time"
| "compare_time"
| "calculate"
| "osv_check"
| "fetch_artifact"
| "describe_image"
| "consult_specialist"
| "report_progress"
| "subagents_list"
| "subagents_info"
)
{
return ToolResult::error(format!(
"Tool `{name}` is not available to Work Subagents; Workspace writes are owned by the Main Agent"
))
.with_error_type("work_subagent_read_only");
}
if auth.principal == "learning_foundry"
&& !matches!(
name,
"web_search"
| "web_fetch"
| "deep_research"
| "read_memory"
| "session_search"
| "read_file"
| "grep"
| "glob"
| "time_math"
| "get_current_time"
| "compare_time"
| "calculate"
)
{
return ToolResult::error(format!(
"Tool `{name}` is not available to the read-only Learning Foundry principal"
))
.with_error_type("learning_foundry_read_only");
}
let effective_risk = self.effective_tool_risk(name);
match crate::tool_guardrails::evaluate_tool_policy_for_auth_with_risk(
&self.config.tool_policy,
name,
auth,
effective_risk,
) {
crate::tool_guardrails::PolicyDecision::Allow => {}
decision => {
let (action, status, block_reason) = match decision {
crate::tool_guardrails::PolicyDecision::Warn(reason) => {
("warn", "allowed", Err(reason))
}
crate::tool_guardrails::PolicyDecision::Block(reason) => {
("block", "blocked", Ok(reason))
}
crate::tool_guardrails::PolicyDecision::Allow => unreachable!(),
};
let reason = match &block_reason {
Ok(r) | Err(r) => r.clone(),
};
tracing::warn!(
tool = name,
chat_id = auth.caller_chat_id,
principal = %auth.principal,
action,
%reason,
"Tool policy violation"
);
if let Some(db) = self.audit_db.clone() {
let tool = name.to_string();
let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
let detail = reason.clone();
tokio::spawn(async move {
let _ = crate::internal::storage::db::call_blocking(db, move |db| {
db.log_audit_event(
"tool_policy",
&actor,
action,
Some(&tool),
status,
Some(&detail),
)
})
.await;
});
}
if block_reason.is_ok() {
return ToolResult::error(format!(
"Tool call blocked by policy: {reason}. Do not retry this tool; \
use a permitted tool or explain the limitation to the user."
))
.with_error_type("tool_policy_blocked");
}
}
}
for (url, decision) in crate::internal::tool_runtime::egress::evaluate_tool_input(
&self.config.egress_policy,
&input,
) {
let (action, status, reason, blocked) = match decision {
crate::internal::tool_runtime::egress::EgressDecision::Allow => continue,
crate::internal::tool_runtime::egress::EgressDecision::Warn(reason) => {
("warn", "allowed", reason, false)
}
crate::internal::tool_runtime::egress::EgressDecision::Block(reason) => {
("block", "blocked", reason, true)
}
};
tracing::warn!(
tool = name,
chat_id = auth.caller_chat_id,
principal = %auth.principal,
%url,
action,
%reason,
"Egress policy violation"
);
if let Some(db) = self.audit_db.clone() {
let tool = name.to_string();
let actor = format!("chat:{}:{}", auth.caller_chat_id, auth.principal);
let detail = format!("{reason}; url={url}");
tokio::spawn(async move {
let _ = crate::internal::storage::db::call_blocking(db, move |db| {
db.log_audit_event(
"egress_policy",
&actor,
action,
Some(&tool),
status,
Some(&detail),
)
})
.await;
});
}
if blocked {
return ToolResult::error(format!(
"Tool call blocked by egress policy: {reason}. Do not retry this destination."
))
.with_error_type("egress_policy_blocked");
}
}
if let Err(msg) =
validate_execution_policy(name, self.sandbox_mode, self.sandbox_runtime_available)
{
return ToolResult::error(msg).with_error_type("execution_policy_blocked");
}
let mut input = input;
if self.config.high_risk_tool_user_confirmation_required {
let gateable = effective_risk == ToolRisk::High || name == "a2a_send";
if gateable {
let standing = if let Some(db) = self.audit_db.clone() {
let key = format!("approved_tool:{}:{}", auth.caller_chat_id, name);
crate::internal::storage::db::call_blocking(db, move |db| {
db.get_runtime_meta(&key)
})
.await
.ok()
.flatten()
.is_some()
} else {
false
};
if standing {
tracing::info!(
tool = name,
chat_id = auth.caller_chat_id,
"High-risk tool allowed by standing per-chat approval"
);
if let serde_json::Value::Object(map) = &mut input {
map.insert(
"__microclaw_high_risk_approved".to_string(),
serde_json::Value::Bool(true),
);
}
} else if let Some(blocked) =
require_high_risk_approval_with_risk(name, effective_risk, auth, &input)
{
return blocked;
}
}
}
tracing::debug!(
tool = name,
risk = effective_risk.as_str(),
execution_policy = tool_execution_policy(name).as_str(),
sandbox_mode = ?self.sandbox_mode,
sandbox_runtime_available = self.sandbox_runtime_available,
"tool execution policy evaluated"
);
let input = Self::inject_default_chat_id_if_missing(name, input, auth);
let input = inject_auth_context(input, auth);
let result = self.execute(name, input.clone()).await;
if result.error_type.as_deref() == Some("unknown_tool") {
if let Some(dynamic) =
crate::plugins::execute_dynamic_plugin_tool(&self.config, name, input).await
{
return dynamic;
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WorkingDirIsolation;
use async_trait::async_trait;
use serde_json::json;
#[test]
fn test_tool_result_success() {
let r = ToolResult::success("ok".into());
assert_eq!(r.content, "ok");
assert!(!r.is_error);
}
#[test]
fn test_tool_result_error() {
let r = ToolResult::error("fail".into());
assert_eq!(r.content, "fail");
assert!(r.is_error);
}
#[test]
fn test_schema_object() {
let schema = schema_object(
json!({
"name": {"type": "string"},
"age": {"type": "integer"}
}),
&["name"],
);
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["name"].is_object());
assert!(schema["properties"]["age"].is_object());
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "name");
}
#[test]
fn test_schema_object_empty_required() {
let schema = schema_object(json!({}), &[]);
let required = schema["required"].as_array().unwrap();
assert!(required.is_empty());
}
#[test]
fn test_auth_context_from_input() {
let input = json!({
"__microclaw_auth": {
"caller_channel": "telegram",
"caller_chat_id": 123,
"control_chat_ids": [123, 999]
}
});
let auth = auth_context_from_input(&input).unwrap();
assert_eq!(auth.caller_channel, "telegram");
assert_eq!(auth.caller_chat_id, 123);
assert!(auth.is_control_chat());
assert!(auth.can_access_chat(456));
}
#[test]
fn test_authorize_chat_access_denied() {
let input = json!({
"__microclaw_auth": {
"caller_channel": "telegram",
"caller_chat_id": 100,
"control_chat_ids": []
}
});
let err = authorize_chat_access(&input, 200).unwrap_err();
assert!(err.contains("Permission denied"));
}
#[test]
fn test_resolve_tool_working_dir_shared() {
let dir = resolve_tool_working_dir(
std::path::Path::new("/tmp/work"),
WorkingDirIsolation::Shared,
&json!({
"__microclaw_auth": {
"caller_channel": "telegram",
"caller_chat_id": 123,
"control_chat_ids": []
}
}),
);
assert_eq!(dir, std::path::PathBuf::from("/tmp/work/shared"));
}
#[test]
fn test_resolve_tool_working_dir_direct() {
let dir = resolve_tool_working_dir(
std::path::Path::new("/tmp/work"),
WorkingDirIsolation::Direct,
&json!({
"__microclaw_auth": {
"caller_channel": "headless",
"caller_chat_id": 123,
"control_chat_ids": []
}
}),
);
assert_eq!(dir, std::path::PathBuf::from("/tmp/work"));
}
#[test]
fn test_resolve_tool_working_dir_chat() {
let dir = resolve_tool_working_dir(
std::path::Path::new("/tmp/work"),
WorkingDirIsolation::Chat,
&json!({
"__microclaw_auth": {
"caller_channel": "discord",
"caller_chat_id": -100123,
"control_chat_ids": []
}
}),
);
assert_eq!(
dir,
std::path::PathBuf::from("/tmp/work/chat/discord/neg100123")
);
}
struct DummyTool {
tool_name: String,
}
#[async_trait]
impl Tool for DummyTool {
fn name(&self) -> &str {
&self.tool_name
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.tool_name.clone(),
description: "dummy".into(),
input_schema: schema_object(json!({}), &[]),
}
}
async fn execute(&self, _input: serde_json::Value) -> ToolResult {
ToolResult::success("ok".into())
}
}
struct CaptureInputTool {
tool_name: String,
}
#[async_trait]
impl Tool for CaptureInputTool {
fn name(&self) -> &str {
&self.tool_name
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.tool_name.clone(),
description: "capture".into(),
input_schema: schema_object(json!({}), &[]),
}
}
async fn execute(&self, input: serde_json::Value) -> ToolResult {
ToolResult::success(input.to_string())
}
}
#[test]
fn test_tool_risk_levels() {
assert_eq!(tool_risk("bash"), ToolRisk::High);
assert_eq!(tool_risk("write_file"), ToolRisk::Medium);
assert_eq!(tool_risk("pause_scheduled_task"), ToolRisk::Medium);
assert_eq!(tool_risk("sync_skills"), ToolRisk::Medium);
assert_eq!(tool_risk("read_file"), ToolRisk::Low);
}
#[tokio::test]
async fn test_tool_policy_blocks_at_choke_point() {
let mut config = crate::config::Config::test_defaults();
config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Block;
config.tool_policy.deny_tools = vec!["bash".to_string()];
let registry = ToolRegistry {
config,
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "telegram".into(),
caller_chat_id: 1,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(result.is_error);
assert_eq!(result.error_type.as_deref(), Some("tool_policy_blocked"));
let mut warn_config = crate::config::Config::test_defaults();
warn_config.tool_policy.mode = crate::tool_guardrails::ToolPolicyMode::Warn;
warn_config.tool_policy.deny_tools = vec!["bash".to_string()];
warn_config.high_risk_tool_user_confirmation_required = false;
let registry = ToolRegistry {
config: warn_config,
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let result = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(
!result.is_error,
"warn mode must still execute: {}",
result.content
);
}
#[tokio::test]
async fn learning_foundry_principal_is_read_only_at_choke_point() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![
Box::new(DummyTool {
tool_name: "write_file".into(),
}),
Box::new(DummyTool {
tool_name: "web_search".into(),
}),
],
};
let auth = ToolAuthContext {
caller_channel: "learning_foundry".into(),
caller_chat_id: -7,
principal: "learning_foundry".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let write = registry
.execute_with_auth("write_file", json!({}), &auth)
.await;
assert!(write.is_error);
assert_eq!(
write.error_type.as_deref(),
Some("learning_foundry_read_only")
);
let search = registry
.execute_with_auth("web_search", json!({}), &auth)
.await;
assert!(!search.is_error);
}
#[tokio::test]
async fn work_subagent_is_read_only_but_main_agent_can_write() {
let make_registry = || ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![
Box::new(DummyTool {
tool_name: "write_file".into(),
}),
Box::new(DummyTool {
tool_name: "read_file".into(),
}),
],
};
let mut auth = ToolAuthContext {
caller_channel: "work".into(),
caller_chat_id: 1,
principal: "subagent:run-1".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let denied = make_registry()
.execute_with_auth("write_file", json!({}), &auth)
.await;
assert!(denied.is_error);
assert_eq!(
denied.error_type.as_deref(),
Some("work_subagent_read_only")
);
let read = make_registry()
.execute_with_auth("read_file", json!({}), &auth)
.await;
assert!(!read.is_error);
auth.principal = "main".into();
let write = make_registry()
.execute_with_auth("write_file", json!({}), &auth)
.await;
assert!(!write.is_error);
}
#[tokio::test]
async fn test_high_risk_tool_requires_explicit_approval_on_web() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "web".into(),
caller_chat_id: 1,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let first = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(first.is_error);
assert_eq!(first.error_type.as_deref(), Some("approval_required"));
let second = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(second.is_error);
assert_eq!(second.error_type.as_deref(), Some("approval_required"));
let approved = registry
.execute_with_auth(
"bash",
json!({"__microclaw_high_risk_approved": true}),
&auth,
)
.await;
assert!(!approved.is_error);
assert_eq!(approved.content, "ok");
}
#[tokio::test]
async fn test_high_risk_tool_requires_explicit_approval_on_control_chat() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "telegram".into(),
caller_chat_id: 123,
principal: "main".into(),
control_chat_ids: vec![123],
env_files: vec![],
};
let first = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(first.is_error);
assert_eq!(first.error_type.as_deref(), Some("approval_required"));
let approved = registry
.execute_with_auth(
"bash",
json!({"__microclaw_high_risk_approved": true}),
&auth,
)
.await;
assert!(!approved.is_error);
assert_eq!(approved.content, "ok");
}
#[tokio::test]
async fn test_high_risk_tool_confirmation_flag_false_hard_disables_web_approval_gate() {
let mut config = crate::config::Config::test_defaults();
config.high_risk_tool_user_confirmation_required = false;
let registry = ToolRegistry {
config,
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "web".into(),
caller_chat_id: 1,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(!result.is_error);
assert_eq!(result.content, "ok");
}
#[tokio::test]
async fn test_high_risk_tool_confirmation_flag_false_hard_disables_control_chat_approval_gate()
{
let mut config = crate::config::Config::test_defaults();
config.high_risk_tool_user_confirmation_required = false;
let registry = ToolRegistry {
config,
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "bash".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "telegram".into(),
caller_chat_id: 123,
principal: "main".into(),
control_chat_ids: vec![123],
env_files: vec![],
};
let result = registry.execute_with_auth("bash", json!({}), &auth).await;
assert!(!result.is_error);
assert_eq!(result.content, "ok");
}
#[tokio::test]
async fn test_medium_risk_tool_no_second_approval() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(DummyTool {
tool_name: "write_file".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "web".into(),
caller_chat_id: 1,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry
.execute_with_auth("write_file", json!({}), &auth)
.await;
assert!(!result.is_error);
assert_eq!(result.content, "ok");
}
#[tokio::test]
async fn test_dynamic_plugin_tool_executes_without_restart() {
let root = std::env::temp_dir().join(format!("microclaw_plugin_{}", uuid::Uuid::new_v4()));
let plugins_dir = root.join("plugins");
std::fs::create_dir_all(&plugins_dir).unwrap();
let manifest = plugins_dir.join("demo.yaml");
std::fs::write(
&manifest,
r#"
name: demo
enabled: true
tools:
- name: plugin_runtime_echo
description: runtime echo
input_schema:
type: object
properties: {}
required: []
run:
command: "echo plugin-ok"
timeout_secs: 5
"#,
)
.unwrap();
let mut config = crate::config::Config::test_defaults();
config.working_dir = root.join("work").to_string_lossy().to_string();
config.plugins.enabled = true;
config.plugins.dir = Some(plugins_dir.to_string_lossy().to_string());
let registry = ToolRegistry {
config,
tools: vec![],
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
};
let auth = ToolAuthContext {
caller_channel: "web".into(),
caller_chat_id: 7,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let defs = registry.definitions();
assert!(defs.iter().any(|d| d.name == "plugin_runtime_echo"));
let result = registry
.execute_with_auth("plugin_runtime_echo", json!({}), &auth)
.await;
assert!(!result.is_error, "{}", result.content);
assert!(result.content.contains("plugin-ok"));
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn test_injects_default_chat_id_for_memory_tools() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(CaptureInputTool {
tool_name: "write_memory".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "feishu".into(),
caller_chat_id: 8009499081,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry
.execute_with_auth(
"write_memory",
json!({"scope":"chat","content":"hello"}),
&auth,
)
.await;
assert!(!result.is_error);
let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(payload["chat_id"].as_i64(), Some(8009499081));
}
#[tokio::test]
async fn test_does_not_override_existing_chat_id() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(CaptureInputTool {
tool_name: "write_memory".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "feishu".into(),
caller_chat_id: 8009499081,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry
.execute_with_auth(
"write_memory",
json!({"scope":"chat","chat_id":42,"content":"hello"}),
&auth,
)
.await;
assert!(!result.is_error);
let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(payload["chat_id"].as_i64(), Some(42));
}
#[tokio::test]
async fn test_injects_default_chat_id_for_send_message_tool() {
let registry = ToolRegistry {
config: crate::config::Config::test_defaults(),
sandbox_mode: SandboxMode::Off,
sandbox_runtime_available: false,
cached_static_definitions: OnceLock::new(),
audit_db: None,
risk_overrides: std::collections::HashMap::new(),
tools: vec![Box::new(CaptureInputTool {
tool_name: "send_message".into(),
})],
};
let auth = ToolAuthContext {
caller_channel: "web".into(),
caller_chat_id: 9001,
principal: "main".into(),
control_chat_ids: vec![],
env_files: vec![],
};
let result = registry
.execute_with_auth("send_message", json!({"text":"progress update"}), &auth)
.await;
assert!(!result.is_error);
let payload: serde_json::Value = serde_json::from_str(&result.content).unwrap();
assert_eq!(payload["chat_id"].as_i64(), Some(9001));
assert_eq!(payload["text"].as_str(), Some("progress update"));
}
}