use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex};
use tracing::Instrument;
use crate::agent::PermissionDecision;
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
use crate::permissions::auto_classifier::AutoClassifier;
use crate::permissions::SharedPermissions;
use crate::permissions::{DecisionReason, Permission, PermissionMode, PermissionsConfig};
use tokio::sync::RwLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolSideEffect {
ReadOnly,
Mutating,
External,
}
pub const AUDIT_ERR_MAX_BYTES: usize = 512;
#[inline]
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ExitStatus {
Ok,
Err {
message: String,
#[serde(default, skip_serializing_if = "is_false")]
truncated: bool,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditMeta {
pub step_id: String,
pub started_at: i64,
pub finished_at: i64,
pub args_hash: String,
pub side_effect: ToolSideEffect,
pub exit_status: ExitStatus,
}
impl AuditMeta {
pub fn synthetic_unknown_tool(name: &str) -> Self {
let now = unix_millis();
Self {
step_id: uuid::Uuid::now_v7().hyphenated().to_string(),
started_at: now,
finished_at: now,
args_hash: String::new(),
side_effect: ToolSideEffect::External,
exit_status: ExitStatus::Err {
message: format!("unknown tool: {name}"),
truncated: false,
},
}
}
}
pub struct ToolDispatch {
pub result: Result<String>,
pub audit: AuditMeta,
}
fn unix_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn truncate_for_audit(s: &str) -> (String, bool) {
if s.len() <= AUDIT_ERR_MAX_BYTES {
return (s.to_string(), false);
}
let mut end = AUDIT_ERR_MAX_BYTES;
while !s.is_char_boundary(end) {
end -= 1;
}
(s[..end].to_string(), true)
}
fn blake3_canonical_json(v: &Value) -> String {
let canonical = v.to_string();
let hash = blake3::hash(canonical.as_bytes());
hash.to_hex().to_string()
}
pub mod a2a;
pub mod apply_patch;
pub mod checkpoint;
#[cfg(feature = "cloud-runtime")]
pub mod docker_provider;
#[cfg(feature = "cloud-runtime")]
pub mod docker_sandbox;
#[cfg(feature = "e2b-sandbox")]
pub mod e2b_provider;
pub mod episodic_recall;
pub mod estimate_tokens;
pub mod facts;
pub mod fs;
pub mod load_skill;
pub mod memory;
pub mod plan_mode;
pub mod policy_sandbox;
pub mod run_background;
pub mod run_skill_script;
pub mod schedule_wakeup;
pub mod search;
pub mod send_message;
pub mod shell;
pub mod spawn_worker;
pub mod str_replace;
pub mod sub_agent;
pub mod team_manage;
pub mod todo;
pub mod transport;
#[cfg(feature = "web_fetch")]
pub mod web_fetch;
pub use a2a::{A2aCallTool, A2aCardTool, A2aTaskCheckTool};
pub use apply_patch::ApplyPatch;
pub use checkpoint::{build_checkpoint_tools, CheckpointDiff, CheckpointList, CheckpointToolCtx};
pub use episodic_recall::{episodic_recall_summary, EpisodicRecall};
pub use estimate_tokens::EstimateTokens;
pub use facts::{
facts_path, facts_summary, load_facts, search_facts, Fact, FactStore, ForgetFact, RecallFact,
RememberFact, ScoredFact, UpdateFact,
};
pub use fs::{ListDir, ReadFile, WriteFile};
pub use load_skill::LoadSkill;
pub use memory::{
load_scratchpad, scratchpad_path, scratchpad_summary, Scratchpad, ScratchpadDelete,
ScratchpadGet, ScratchpadList, WorkingMemoryTool,
};
pub use memory::{Forget, Recall, Remember};
pub use plan_mode::{
EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanApprovalResult, PlanModeRequestGate,
PlanModeRequestResult, RequestPlanModeTool,
};
pub use policy_sandbox::{FsPolicy, PolicyConfig, ShellPolicy};
pub use run_background::{BackgroundJobManager, CheckBackground, Job, JobState, RunBackground};
pub use run_skill_script::RunSkillScript;
pub use schedule_wakeup::{ScheduleWakeup, WakeupRequest, WakeupSlot};
pub use search::SearchFiles;
pub use send_message::{SendMessageTool, WorkerMailbox, WorkerRegistry};
pub use shell::RunShell;
pub use spawn_worker::{SpawnWorkerTool, WorkerType};
pub use str_replace::StrReplaceTool;
pub use sub_agent::SubAgent;
pub use team_manage::{TeamAddRole, TeamListRoles, TeamRemoveRole};
pub use todo::{TodoItem, TodoStatus, TodoWriteTool};
pub use transport::{DirEntry, ExecResult, LocalTransport, ReadResult, ToolTransport};
#[cfg(feature = "web_fetch")]
pub use web_fetch::WebFetch;
impl Default for ToolRegistry {
fn default() -> Self {
Self::local()
}
}
#[async_trait]
pub trait Tool: Send + Sync {
fn spec(&self) -> ToolSpec;
async fn execute(&self, arguments: Value) -> Result<String>;
fn side_effect_class(&self) -> ToolSideEffect {
ToolSideEffect::External
}
fn is_readonly(&self) -> bool {
matches!(self.side_effect_class(), ToolSideEffect::ReadOnly)
}
fn is_readonly_for_args(&self, _arguments: &Value) -> bool {
self.is_readonly()
}
}
#[async_trait]
pub trait PermissionHook: Send + Sync {
async fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision;
}
#[derive(Clone)]
pub struct ToolRegistry {
tools: BTreeMap<String, Arc<dyn Tool>>,
aliases: BTreeMap<String, String>,
transport: Arc<dyn ToolTransport>,
permissions: Option<SharedPermissions>,
permission_mode: PermissionMode,
touched: Option<Arc<Mutex<TouchedFiles>>>,
permission_hook: Option<Arc<dyn PermissionHook>>,
policy: Option<policy_sandbox::PolicyConfig>,
pub headless: bool,
pub hook_runner: crate::hooks::ExternalHookRunner,
pub auto_classifier: Option<Arc<tokio::sync::Mutex<AutoClassifier>>>,
}
#[derive(Debug, Default, Clone)]
pub struct TouchedFiles {
pub paths: HashSet<String>,
pub saw_shell: bool,
}
impl TouchedFiles {
pub fn new() -> Self {
Self::default()
}
pub fn is_empty(&self) -> bool {
self.paths.is_empty() && !self.saw_shell
}
pub fn paths_sorted(&self) -> Vec<String> {
let mut v: Vec<_> = self.paths.iter().cloned().collect();
v.sort();
v
}
}
fn record_touched(name: &str, args: &Value, slot: &Mutex<TouchedFiles>) {
let Ok(mut t) = slot.lock() else {
return;
};
match name {
"write_file" => {
if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
t.paths.insert(p.to_string());
}
}
"apply_patch" => {
let body = args
.get("patch")
.or_else(|| args.get("input"))
.and_then(|v| v.as_str())
.unwrap_or("");
for line in body.lines() {
for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] {
if let Some(rest) = line.strip_prefix(prefix) {
t.paths.insert(rest.trim().to_string());
}
}
}
}
"run_shell" => {
t.saw_shell = true;
}
_ => {}
}
}
impl ToolRegistry {
pub fn new(transport: Arc<dyn ToolTransport>) -> Self {
Self {
tools: BTreeMap::new(),
aliases: BTreeMap::new(),
transport,
permissions: None,
auto_classifier: None,
permission_mode: PermissionMode::Default,
touched: None,
permission_hook: None,
policy: None,
headless: false,
hook_runner: crate::hooks::ExternalHookRunner::discover(&[]),
}
}
pub fn local() -> Self {
Self::new(Arc::new(LocalTransport))
}
pub fn transport(&self) -> &Arc<dyn ToolTransport> {
&self.transport
}
pub fn with_same_transport(&self) -> Self {
Self {
tools: BTreeMap::new(),
aliases: BTreeMap::new(),
transport: self.transport.clone(),
permissions: self.permissions.clone(),
auto_classifier: self.auto_classifier.clone(),
permission_mode: self.permission_mode.clone(),
touched: self.touched.clone(),
permission_hook: self.permission_hook.clone(),
policy: self.policy.clone(),
headless: self.headless,
hook_runner: self.hook_runner.clone(),
}
}
pub fn with_permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
self.permission_hook = Some(hook);
self
}
pub fn set_permission_hook(&mut self, hook: Arc<dyn PermissionHook>) {
self.permission_hook = Some(hook);
}
pub fn clear_permission_hook(&mut self) {
self.permission_hook = None;
}
pub fn with_policy(mut self, policy: policy_sandbox::PolicyConfig) -> Self {
self.policy = Some(policy);
self
}
pub fn set_policy(&mut self, policy: policy_sandbox::PolicyConfig) {
self.policy = Some(policy);
}
pub fn policy(&self) -> Option<&policy_sandbox::PolicyConfig> {
self.policy.as_ref()
}
pub fn with_headless(mut self, headless: bool) -> Self {
self.headless = headless;
self
}
pub fn set_headless(&mut self, headless: bool) {
self.headless = headless;
}
pub fn with_hook_runner(mut self, hook_runner: crate::hooks::ExternalHookRunner) -> Self {
self.hook_runner = hook_runner;
self
}
pub fn set_hook_runner(&mut self, hook_runner: crate::hooks::ExternalHookRunner) {
self.hook_runner = hook_runner;
}
pub fn with_permissions(mut self, permissions: PermissionsConfig) -> Self {
self.permission_mode = permissions.mode.clone();
self.permissions = Some(Arc::new(RwLock::new(permissions)));
self
}
pub fn with_shared_permissions(mut self, sp: SharedPermissions) -> Self {
if let Ok(guard) = sp.try_read() {
self.permission_mode = guard.mode.clone();
}
self.permissions = Some(sp);
self
}
pub fn with_auto_classifier(mut self, classifier: AutoClassifier) -> Self {
self.auto_classifier = Some(Arc::new(tokio::sync::Mutex::new(classifier)));
self
}
pub fn permission_mode(&self) -> PermissionMode {
self.permission_mode.clone()
}
pub fn permissions_config(&self) -> Option<PermissionsConfig> {
self.permissions
.as_ref()
.and_then(|sp| sp.try_read().ok())
.map(|guard| guard.clone())
}
pub fn is_plan_mode(&self, tool_name: &str) -> bool {
self.permissions
.as_ref()
.and_then(|sp| sp.try_read().ok())
.map(|guard| guard.is_plan_mode(tool_name))
.unwrap_or(false)
}
pub fn with_touched_files(mut self, slot: Arc<Mutex<TouchedFiles>>) -> Self {
self.touched = Some(slot);
self
}
pub fn clear_touched_files(&mut self) {
self.touched = None;
}
pub fn touched_files(&self) -> Option<Arc<Mutex<TouchedFiles>>> {
self.touched.clone()
}
pub fn register(mut self, tool: Arc<dyn Tool>) -> Self {
let name = tool.spec().name;
self.tools.insert(name, tool);
self
}
pub fn register_with_aliases(mut self, tool: Arc<dyn Tool>, aliases: &[&str]) -> Self {
let name = tool.spec().name.clone();
for &alias in aliases {
self.aliases.insert(alias.to_string(), name.clone());
}
self.tools.insert(name, tool);
self
}
pub fn register_mut(&mut self, tool: Arc<dyn Tool>) {
let name = tool.spec().name;
self.tools.insert(name, tool);
}
pub fn register_mut_with_aliases(&mut self, tool: Arc<dyn Tool>, aliases: &[&str]) {
let name = tool.spec().name.clone();
for &alias in aliases {
self.aliases.insert(alias.to_string(), name.clone());
}
self.tools.insert(name, tool);
}
pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn Tool>> {
if let Some(tool) = self.tools.get(name) {
return Some(tool.clone());
}
if let Some(primary) = self.aliases.get(name) {
return self.tools.get(primary).cloned();
}
None
}
pub fn specs(&self) -> Vec<ToolSpec> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn names(&self) -> Vec<String> {
self.tools.keys().cloned().collect()
}
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
pub fn is_readonly(&self, name: &str) -> bool {
self.tools
.get(name)
.map(|t| t.is_readonly())
.unwrap_or(false)
}
pub fn is_readonly_for_call(&self, name: &str, args: &Value) -> bool {
self.tools
.get(name)
.map(|t| t.is_readonly_for_args(args))
.unwrap_or(false)
}
pub async fn invoke(&self, name: &str, arguments: Value) -> Result<String> {
let effective_args = if let Some(hook) = &self.permission_hook {
match hook.check(name, &arguments).await {
PermissionDecision::Allow => arguments,
PermissionDecision::Transform(new_args) => new_args,
PermissionDecision::Deny(reason) => {
return Err(Error::PermissionDenied {
name: name.into(),
reason: DecisionReason::Hook { name: reason },
});
}
}
} else {
arguments
};
self.invoke_with_audit(name, effective_args).await.result
}
pub async fn invoke_with_audit(&self, name: &str, mut arguments: Value) -> ToolDispatch {
let safety_content = safety_content_for_tool(name, &arguments);
if let Some(ref sp) = self.permissions {
let guard = sp.read().await;
if matches!(guard.mode, PermissionMode::Auto) {
if let Some(ref classifier) = self.auto_classifier {
let args_summary =
serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".into());
let mut c = classifier.lock().await;
match c.classify(name, &args_summary, "").await {
Ok((true, _reason)) => {
if c.tracker.is_over_limit() {
return ToolDispatch {
result: Err(Error::PermissionDeniedLimit { name: name.into() }),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
return ToolDispatch {
result: Err(Error::PermissionDenied {
name: name.into(),
reason: DecisionReason::Mode(PermissionMode::Auto),
}),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
Ok((false, _)) => {
}
Err(_e) => {
}
}
}
}
let is_readonly = self.is_readonly(name);
let is_interactive_tool = guard.any_interactive(name);
let mut perm_is_unknown = false;
match guard.check_static(name, is_readonly, safety_content.as_deref()) {
Permission::Denied(reason, _msg) => {
return ToolDispatch {
result: Err(Error::PermissionDenied {
name: name.into(),
reason: reason.clone(),
}),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
Permission::Unknown => {
perm_is_unknown = true;
}
Permission::Allowed(_) => {}
}
if perm_is_unknown && !self.headless && is_interactive_tool {
if let Some(hook) = &self.permission_hook {
drop(guard);
match hook.check(name, &arguments).await {
PermissionDecision::Deny(reason) => {
return ToolDispatch {
result: Err(Error::PermissionDenied {
name: name.into(),
reason: DecisionReason::Hook { name: reason },
}),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
PermissionDecision::Transform(new_args) => {
arguments = new_args;
}
PermissionDecision::Allow => {}
}
} else {
drop(guard);
}
} else
if self.headless && is_interactive_tool {
if self.hook_runner.is_empty() {
return ToolDispatch {
result: Err(Error::PermissionDenied {
name: name.into(),
reason: DecisionReason::Hook {
name: "PermissionRequest".into(),
},
}),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
let hook_input = crate::hooks::external::HookInput {
event: crate::hooks::external::HookEvent::PermissionRequest,
tool_name: Some(name.to_string()),
args: Some(arguments.clone()),
mode: format!("{:?}", self.permission_mode),
content: None,
message: None,
depth: None,
reason: None,
error: None,
};
drop(guard);
let hook_result = self.hook_runner.dispatch(&hook_input).await;
if !matches!(hook_result.action, crate::hooks::HookAction::Continue) {
return ToolDispatch {
result: Err(Error::PermissionDenied {
name: name.into(),
reason: DecisionReason::Hook {
name: "PermissionRequest".into(),
},
}),
audit: AuditMeta::synthetic_unknown_tool(name),
};
}
} else {
drop(guard);
}
}
if let Some(slot) = &self.touched {
record_touched(name, &arguments, slot);
}
let Some(tool) = self.find_by_name(name) else {
return ToolDispatch {
result: Err(Error::UnknownTool(name.into())),
audit: AuditMeta::synthetic_unknown_tool(name),
};
};
let side_effect = tool.side_effect_class();
let step_id = uuid::Uuid::now_v7().hyphenated().to_string();
let args_hash = blake3_canonical_json(&arguments);
let started_at = unix_millis();
let args_size = arguments.to_string().len();
let span = tracing::info_span!("tool.execute", name = %name, args_size);
let raw_result = tool
.execute(arguments)
.instrument(span)
.await
.map_err(|e| match e {
Error::Tool { .. } | Error::BadToolArgs { .. } | Error::UnknownTool(_) => e,
other => Error::Tool {
name: name.into(),
message: other.to_string(),
},
});
let finished_at = unix_millis();
let exit_status = match &raw_result {
Ok(_) => ExitStatus::Ok,
Err(e) => {
let (clipped, truncated) = truncate_for_audit(&e.to_string());
ExitStatus::Err {
message: clipped,
truncated,
}
}
};
ToolDispatch {
result: raw_result,
audit: AuditMeta {
step_id,
started_at,
finished_at,
args_hash,
side_effect,
exit_status,
},
}
}
}
pub fn args_preview_for_permission(arguments: &Value) -> String {
let s = match arguments {
Value::Object(map) => {
let parts: Vec<String> = map
.iter()
.take(3)
.map(|(k, v)| {
let v_str = match v {
Value::String(s) => {
let short: String = s.chars().take(30).collect();
format!("\"{}\"", short)
}
other => {
let s = other.to_string();
s.chars().take(30).collect()
}
};
format!("{k}={v_str}")
})
.collect();
parts.join(", ")
}
other => other.to_string(),
};
if s.chars().count() > 80 {
let head: String = s.chars().take(79).collect();
format!("{head}…")
} else {
s
}
}
pub(crate) fn resolve_within(root: &std::path::Path, path: &str) -> Result<std::path::PathBuf> {
let candidate = std::path::Path::new(path);
let joined = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(candidate)
};
let abs_root = absolutise(root);
let abs_joined = absolutise(&joined);
if !abs_joined.starts_with(&abs_root) {
return Err(Error::BadToolArgs {
name: "<fs>".into(),
message: format!(
"path `{}` escapes workspace root `{}`",
path,
abs_root.display()
),
});
}
Ok(abs_joined)
}
fn absolutise(p: &std::path::Path) -> std::path::PathBuf {
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
.join(p)
};
normalise(&abs)
}
fn normalise(p: &std::path::Path) -> std::path::PathBuf {
let mut out = std::path::PathBuf::new();
for c in p.components() {
use std::path::Component::*;
match c {
ParentDir => {
out.pop();
}
CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn build_standard_tools(
workspace: &std::path::Path,
skills: &[crate::skills::Skill],
shell_timeout_secs: u64,
) -> ToolRegistry {
let bg_manager = Arc::new(tokio::sync::Mutex::new(BackgroundJobManager::new()));
let todo_list = Arc::new(std::sync::RwLock::new(Vec::<TodoItem>::new()));
let mut registry = ToolRegistry::local()
.register(Arc::new(ReadFile::new(workspace)))
.register(Arc::new(WriteFile::new(workspace)))
.register(Arc::new(ApplyPatch::new(workspace)))
.register(Arc::new(StrReplaceTool::new(workspace)))
.register(Arc::new(ListDir::new(workspace)))
.register(Arc::new(
RunShell::new(workspace)
.with_timeout(std::time::Duration::from_secs(shell_timeout_secs)),
))
.register(Arc::new(SearchFiles::new(workspace)))
.register(Arc::new(RunBackground::new(workspace, bg_manager.clone())))
.register(Arc::new(CheckBackground::new(bg_manager)))
.register(Arc::new(EstimateTokens::new(workspace)))
.register(Arc::new(Remember::new(workspace)))
.register(Arc::new(Recall::new(workspace)))
.register(Arc::new(Forget::new(workspace)))
.register(Arc::new(RememberFact::new(workspace)))
.register(Arc::new(RecallFact::new(workspace)))
.register(Arc::new(ForgetFact::new(workspace)))
.register(Arc::new(UpdateFact::new(workspace)))
.register(Arc::new(EpisodicRecall::new(workspace)))
.register(Arc::new(WorkingMemoryTool::new(workspace)))
.register(Arc::new(ScratchpadGet::new(workspace)))
.register(Arc::new(ScratchpadDelete::new(workspace)))
.register(Arc::new(ScratchpadList::new(workspace)))
.register(Arc::new(TodoWriteTool::new(
todo_list,
Arc::new(crate::event::NullSink),
)))
.register(Arc::new(A2aCallTool::new()))
.register(Arc::new(A2aCardTool::new()))
.register(Arc::new(A2aTaskCheckTool::new()));
#[cfg(feature = "web_fetch")]
{
registry = registry.register(Arc::new(WebFetch::new()));
}
if !skills.is_empty() {
registry = registry
.register(Arc::new(LoadSkill::new(skills.to_vec())))
.register(Arc::new(RunSkillScript::new(
skills.to_vec(),
workspace.to_path_buf(),
std::time::Duration::from_secs(shell_timeout_secs),
)));
}
registry
}
fn safety_content_for_tool(name: &str, args: &serde_json::Value) -> Option<String> {
match name {
"write_file" | "read_file" => args["path"].as_str().map(String::from),
"apply_patch" => args["patch"].as_str().map(String::from),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::PermissionMode;
use async_trait::async_trait;
struct Echo;
#[async_trait]
impl Tool for Echo {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "echo".into(),
description: "echo".into(),
parameters: serde_json::json!({"type":"object","properties":{"msg":{"type":"string"}}}),
}
}
async fn execute(&self, args: Value) -> Result<String> {
Ok(args["msg"].as_str().unwrap_or("").into())
}
}
#[tokio::test]
async fn registry_dispatches_and_errors_on_unknown() {
let reg = ToolRegistry::local().register(Arc::new(Echo));
let out = reg
.invoke("echo", serde_json::json!({"msg":"hi"}))
.await
.unwrap();
assert_eq!(out, "hi");
let err = reg.invoke("nope", serde_json::json!({})).await.unwrap_err();
assert!(matches!(err, Error::UnknownTool(_)));
}
#[test]
fn resolve_within_rejects_escape() {
let root = std::path::Path::new("/work");
assert!(resolve_within(root, "../etc/passwd").is_err());
assert!(resolve_within(root, "/elsewhere").is_err());
assert!(resolve_within(root, "src/lib.rs").is_ok());
}
#[test]
fn resolve_within_handles_relative_root() {
let cwd = std::env::current_dir().unwrap();
let resolved = resolve_within(std::path::Path::new("."), "src/lib.rs").unwrap();
assert!(resolved.starts_with(&cwd));
assert!(resolved.ends_with("src/lib.rs"));
}
#[tokio::test]
async fn test_permission_deny_blocks_invoke() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
allow: vec!["echo".into()],
deny: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.register(Arc::new(Echo));
let err = reg
.invoke("echo", serde_json::json!({"msg":"hi"}))
.await
.unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
struct AllowHook;
struct DenyHook;
#[async_trait]
impl PermissionHook for AllowHook {
async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
PermissionDecision::Allow
}
}
#[async_trait]
impl PermissionHook for DenyHook {
async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
PermissionDecision::Deny("denied by test hook".to_string())
}
}
#[tokio::test]
async fn permission_hook_allow_lets_tool_run() {
let reg = ToolRegistry::local()
.with_permission_hook(Arc::new(AllowHook))
.register(Arc::new(Echo));
let result = reg
.invoke("echo", serde_json::json!({"msg": "hello"}))
.await
.unwrap();
assert_eq!(result, "hello");
}
#[tokio::test]
async fn permission_hook_deny_blocks_invoke() {
let reg = ToolRegistry::local()
.with_permission_hook(Arc::new(DenyHook))
.register(Arc::new(Echo));
let err = reg
.invoke("echo", serde_json::json!({"msg": "blocked"}))
.await
.unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[test]
fn args_preview_truncates_long_strings() {
let big_val = "x".repeat(200);
let args = serde_json::json!({"command": big_val});
let preview = args_preview_for_permission(&args);
assert!(preview.chars().count() <= 81); }
#[tokio::test]
async fn headless_interactive_tool_denied_without_hooks() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_headless(true)
.register(Arc::new(Echo));
let err = reg
.invoke("echo", serde_json::json!({"msg": "hi"}))
.await
.unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
#[tokio::test]
async fn headless_interactive_tool_allowed_by_hook() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let hook_path = tmp.path().join("allow.sh");
let script = "#!/bin/sh\nread -r _\necho '{\"action\":\"continue\"}'\n";
std::fs::write(&hook_path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms).unwrap();
}
let hook_runner = crate::hooks::ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_headless(true)
.with_hook_runner(hook_runner)
.register(Arc::new(Echo));
#[cfg(unix)]
{
let result = reg
.invoke("echo", serde_json::json!({"msg": "allowed"}))
.await
.unwrap();
assert_eq!(result, "allowed");
}
#[cfg(not(unix))]
{
let err = reg
.invoke("echo", serde_json::json!({"msg": "blocked"}))
.await
.unwrap_err();
assert!(matches!(err, Error::PermissionDenied { .. }));
}
}
#[tokio::test]
async fn non_headless_interactive_not_auto_denied() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_headless(false)
.register(Arc::new(Echo));
let result = reg
.invoke("echo", serde_json::json!({"msg": "hello"}))
.await
.unwrap();
assert_eq!(result, "hello");
}
#[test]
fn permission_unknown_variant_exists() {
use crate::permissions::Permission;
let u = Permission::Unknown;
assert!(!u.is_allowed());
assert!(!u.is_denied());
}
#[tokio::test]
async fn unknown_interactive_tool_deny_hook_blocks_invoke_with_audit() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_permission_hook(Arc::new(DenyHook))
.with_headless(false)
.register(Arc::new(Echo));
let dispatch = reg
.invoke_with_audit("echo", serde_json::json!({"msg": "hi"}))
.await;
assert!(
matches!(dispatch.result, Err(Error::PermissionDenied { .. })),
"hook-deny should block interactive Unknown tool via invoke_with_audit"
);
}
#[tokio::test]
async fn unknown_interactive_tool_no_hook_is_allowed() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_headless(false)
.register(Arc::new(Echo));
let dispatch = reg
.invoke_with_audit("echo", serde_json::json!({"msg": "ok"}))
.await;
assert_eq!(
dispatch.result.unwrap(),
"ok",
"no hook = allow for Unknown interactive tool"
);
}
#[tokio::test]
async fn unknown_non_interactive_tool_hook_not_consulted() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_permission_hook(Arc::new(DenyHook))
.with_headless(false)
.register(Arc::new(Echo));
let dispatch = reg
.invoke_with_audit("echo", serde_json::json!({"msg": "pass"}))
.await;
assert_eq!(
dispatch.result.unwrap(),
"pass",
"DenyHook must not fire for non-interactive Unknown tools"
);
}
#[tokio::test]
async fn allowed_interactive_tool_hook_not_consulted() {
let config = crate::permissions::LayeredPermissionsConfig {
mode: PermissionMode::Default,
layers: vec![crate::permissions::PermissionLayer {
source: crate::permissions::RuleSource::User,
allow: vec!["echo".into()],
interactive: vec!["echo".into()],
..Default::default()
}],
};
let reg = ToolRegistry::local()
.with_permissions(config)
.with_permission_hook(Arc::new(DenyHook))
.with_headless(false)
.register(Arc::new(Echo));
let dispatch = reg
.invoke_with_audit("echo", serde_json::json!({"msg": "explicit-allow"}))
.await;
assert_eq!(
dispatch.result.unwrap(),
"explicit-allow",
"Explicitly Allowed tools must not be re-checked via hook"
);
}
#[test]
fn default_registry_has_no_plan_mode_tools() {
let workspace = std::path::PathBuf::from(".");
let registry = build_standard_tools(&workspace, &[], 30);
assert!(
registry.get("enter_plan_mode").is_none(),
"enter_plan_mode must not be in the default registry"
);
assert!(
registry.get("exit_plan_mode").is_none(),
"exit_plan_mode must not be in the default registry"
);
}
}