1use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{BTreeMap, HashSet};
10use std::sync::{Arc, Mutex};
11use tracing::Instrument;
12
13use crate::agent::PermissionDecision;
14use crate::error::{Error, Result};
15use crate::llm::ToolSpec;
16use crate::permissions::auto_classifier::AutoClassifier;
17use crate::permissions::SharedPermissions;
18use crate::permissions::{DecisionReason, Permission, PermissionMode, PermissionsConfig};
19use tokio::sync::RwLock;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ToolSideEffect {
32 ReadOnly,
36 Mutating,
39 External,
44}
45
46pub const AUDIT_ERR_MAX_BYTES: usize = 512;
49
50#[inline]
51fn is_false(b: &bool) -> bool {
52 !b
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum ExitStatus {
59 Ok,
60 Err {
61 message: String,
63 #[serde(default, skip_serializing_if = "is_false")]
65 truncated: bool,
66 },
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct AuditMeta {
73 pub step_id: String,
75 pub started_at: i64,
77 pub finished_at: i64,
79 pub args_hash: String,
82 pub side_effect: ToolSideEffect,
84 pub exit_status: ExitStatus,
86}
87
88impl AuditMeta {
89 pub fn synthetic_unknown_tool(name: &str) -> Self {
92 let now = unix_millis();
93 Self {
94 step_id: uuid::Uuid::now_v7().hyphenated().to_string(),
95 started_at: now,
96 finished_at: now,
97 args_hash: String::new(),
98 side_effect: ToolSideEffect::External,
99 exit_status: ExitStatus::Err {
100 message: format!("unknown tool: {name}"),
101 truncated: false,
102 },
103 }
104 }
105}
106
107pub struct ToolDispatch {
110 pub result: Result<String>,
111 pub audit: AuditMeta,
112}
113
114fn unix_millis() -> i64 {
117 std::time::SystemTime::now()
118 .duration_since(std::time::UNIX_EPOCH)
119 .map(|d| d.as_millis() as i64)
120 .unwrap_or(0)
121}
122
123fn truncate_for_audit(s: &str) -> (String, bool) {
126 if s.len() <= AUDIT_ERR_MAX_BYTES {
127 return (s.to_string(), false);
128 }
129 let mut end = AUDIT_ERR_MAX_BYTES;
130 while !s.is_char_boundary(end) {
131 end -= 1;
132 }
133 (s[..end].to_string(), true)
134}
135
136fn blake3_canonical_json(v: &Value) -> String {
138 let canonical = v.to_string();
139 let hash = blake3::hash(canonical.as_bytes());
140 hash.to_hex().to_string()
141}
142
143pub mod a2a;
144pub mod apply_patch;
145pub mod checkpoint;
146#[cfg(feature = "cloud-runtime")]
147pub mod docker_provider;
148#[cfg(feature = "cloud-runtime")]
149pub mod docker_sandbox;
150#[cfg(feature = "e2b-sandbox")]
151pub mod e2b_provider;
152pub mod episodic_recall;
153pub mod estimate_tokens;
154pub mod facts;
155pub mod fs;
156pub mod load_skill;
157pub mod memory;
158pub mod plan_mode;
159pub mod policy_sandbox;
160pub mod run_background;
161pub mod run_skill_script;
162pub mod schedule_wakeup;
163pub mod search;
164pub mod send_message;
165pub mod shell;
166pub mod spawn_worker;
167pub mod str_replace;
168pub mod sub_agent;
169pub mod team_manage;
170pub mod todo;
171pub mod transport;
172#[cfg(feature = "web_fetch")]
173pub mod web_fetch;
174
175pub use a2a::{A2aCallTool, A2aCardTool, A2aTaskCheckTool};
176pub use apply_patch::ApplyPatch;
177pub use checkpoint::{build_checkpoint_tools, CheckpointDiff, CheckpointList, CheckpointToolCtx};
178pub use episodic_recall::{episodic_recall_summary, EpisodicRecall};
179pub use estimate_tokens::EstimateTokens;
180pub use facts::{
181 facts_path, facts_summary, load_facts, search_facts, Fact, FactStore, ForgetFact, RecallFact,
182 RememberFact, ScoredFact, UpdateFact,
183};
184pub use fs::{ListDir, ReadFile, WriteFile};
185pub use load_skill::LoadSkill;
186pub use memory::{
187 load_scratchpad, scratchpad_path, scratchpad_summary, Scratchpad, ScratchpadDelete,
188 ScratchpadGet, ScratchpadList, WorkingMemoryTool,
189};
190pub use memory::{Forget, Recall, Remember};
191pub use plan_mode::{
192 EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanApprovalResult, PlanModeRequestGate,
193 PlanModeRequestResult, RequestPlanModeTool,
194};
195pub use policy_sandbox::{FsPolicy, PolicyConfig, ShellPolicy};
196pub use run_background::{BackgroundJobManager, CheckBackground, Job, JobState, RunBackground};
197pub use run_skill_script::RunSkillScript;
198pub use schedule_wakeup::{ScheduleWakeup, WakeupRequest, WakeupSlot};
199pub use search::SearchFiles;
200pub use send_message::{SendMessageTool, WorkerMailbox, WorkerRegistry};
201pub use shell::RunShell;
202pub use spawn_worker::{SpawnWorkerTool, WorkerType};
203pub use str_replace::StrReplaceTool;
204pub use sub_agent::SubAgent;
205pub use team_manage::{TeamAddRole, TeamListRoles, TeamRemoveRole};
206pub use todo::{TodoItem, TodoStatus, TodoWriteTool};
207pub use transport::{DirEntry, ExecResult, LocalTransport, ReadResult, ToolTransport};
208#[cfg(feature = "web_fetch")]
209pub use web_fetch::WebFetch;
210
211impl Default for ToolRegistry {
212 fn default() -> Self {
213 Self::local()
214 }
215}
216
217#[async_trait]
218pub trait Tool: Send + Sync {
219 fn spec(&self) -> ToolSpec;
220 async fn execute(&self, arguments: Value) -> Result<String>;
221
222 fn side_effect_class(&self) -> ToolSideEffect {
227 ToolSideEffect::External
228 }
229
230 fn is_readonly(&self) -> bool {
235 matches!(self.side_effect_class(), ToolSideEffect::ReadOnly)
236 }
237
238 fn is_readonly_for_args(&self, _arguments: &Value) -> bool {
244 self.is_readonly()
245 }
246}
247
248#[async_trait]
257pub trait PermissionHook: Send + Sync {
258 async fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision;
260}
261
262#[derive(Clone)]
263pub struct ToolRegistry {
264 tools: BTreeMap<String, Arc<dyn Tool>>,
265 aliases: BTreeMap<String, String>,
268 transport: Arc<dyn ToolTransport>,
269 permissions: Option<SharedPermissions>,
274 permission_mode: PermissionMode,
277 touched: Option<Arc<Mutex<TouchedFiles>>>,
278 permission_hook: Option<Arc<dyn PermissionHook>>,
282 policy: Option<policy_sandbox::PolicyConfig>,
286 pub headless: bool,
289 pub hook_runner: crate::hooks::ExternalHookRunner,
291
292 pub auto_classifier: Option<Arc<tokio::sync::Mutex<AutoClassifier>>>,
297}
298
299#[derive(Debug, Default, Clone)]
304pub struct TouchedFiles {
305 pub paths: HashSet<String>,
308 pub saw_shell: bool,
311}
312
313impl TouchedFiles {
314 pub fn new() -> Self {
315 Self::default()
316 }
317 pub fn is_empty(&self) -> bool {
318 self.paths.is_empty() && !self.saw_shell
319 }
320 pub fn paths_sorted(&self) -> Vec<String> {
321 let mut v: Vec<_> = self.paths.iter().cloned().collect();
322 v.sort();
323 v
324 }
325}
326
327fn record_touched(name: &str, args: &Value, slot: &Mutex<TouchedFiles>) {
330 let Ok(mut t) = slot.lock() else {
331 return;
332 };
333 match name {
334 "write_file" => {
335 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
336 t.paths.insert(p.to_string());
337 }
338 }
339 "apply_patch" => {
340 let body = args
343 .get("patch")
344 .or_else(|| args.get("input"))
345 .and_then(|v| v.as_str())
346 .unwrap_or("");
347 for line in body.lines() {
348 for prefix in ["*** Update File: ", "*** Add File: ", "*** Delete File: "] {
349 if let Some(rest) = line.strip_prefix(prefix) {
350 t.paths.insert(rest.trim().to_string());
351 }
352 }
353 }
354 }
355 "run_shell" => {
356 t.saw_shell = true;
357 }
358 _ => {}
359 }
360}
361
362impl ToolRegistry {
363 pub fn new(transport: Arc<dyn ToolTransport>) -> Self {
364 Self {
365 tools: BTreeMap::new(),
366 aliases: BTreeMap::new(),
367 transport,
368 permissions: None,
369 auto_classifier: None,
370 permission_mode: PermissionMode::Default,
371 touched: None,
372 permission_hook: None,
373 policy: None,
374 headless: false,
375 hook_runner: crate::hooks::ExternalHookRunner::discover(&[]),
376 }
377 }
378
379 pub fn local() -> Self {
381 Self::new(Arc::new(LocalTransport))
382 }
383
384 pub fn transport(&self) -> &Arc<dyn ToolTransport> {
386 &self.transport
387 }
388
389 pub fn with_same_transport(&self) -> Self {
391 Self {
392 tools: BTreeMap::new(),
393 aliases: BTreeMap::new(),
394 transport: self.transport.clone(),
395 permissions: self.permissions.clone(),
396 auto_classifier: self.auto_classifier.clone(),
397 permission_mode: self.permission_mode.clone(),
398 touched: self.touched.clone(),
399 permission_hook: self.permission_hook.clone(),
400 policy: self.policy.clone(),
401 headless: self.headless,
402 hook_runner: self.hook_runner.clone(),
403 }
404 }
405
406 pub fn with_permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
410 self.permission_hook = Some(hook);
411 self
412 }
413
414 pub fn set_permission_hook(&mut self, hook: Arc<dyn PermissionHook>) {
417 self.permission_hook = Some(hook);
418 }
419
420 pub fn clear_permission_hook(&mut self) {
422 self.permission_hook = None;
423 }
424
425 pub fn with_policy(mut self, policy: policy_sandbox::PolicyConfig) -> Self {
429 self.policy = Some(policy);
430 self
431 }
432
433 pub fn set_policy(&mut self, policy: policy_sandbox::PolicyConfig) {
435 self.policy = Some(policy);
436 }
437
438 pub fn policy(&self) -> Option<&policy_sandbox::PolicyConfig> {
440 self.policy.as_ref()
441 }
442
443 pub fn with_headless(mut self, headless: bool) -> Self {
446 self.headless = headless;
447 self
448 }
449
450 pub fn set_headless(&mut self, headless: bool) {
452 self.headless = headless;
453 }
454
455 pub fn with_hook_runner(mut self, hook_runner: crate::hooks::ExternalHookRunner) -> Self {
457 self.hook_runner = hook_runner;
458 self
459 }
460
461 pub fn set_hook_runner(&mut self, hook_runner: crate::hooks::ExternalHookRunner) {
463 self.hook_runner = hook_runner;
464 }
465
466 pub fn with_permissions(mut self, permissions: PermissionsConfig) -> Self {
468 self.permission_mode = permissions.mode.clone();
469 self.permissions = Some(Arc::new(RwLock::new(permissions)));
470 self
471 }
472
473 pub fn with_shared_permissions(mut self, sp: SharedPermissions) -> Self {
481 if let Ok(guard) = sp.try_read() {
483 self.permission_mode = guard.mode.clone();
484 }
485 self.permissions = Some(sp);
486 self
487 }
488
489 pub fn with_auto_classifier(mut self, classifier: AutoClassifier) -> Self {
496 self.auto_classifier = Some(Arc::new(tokio::sync::Mutex::new(classifier)));
497 self
498 }
499
500 pub fn permission_mode(&self) -> PermissionMode {
502 self.permission_mode.clone()
503 }
504
505 pub fn permissions_config(&self) -> Option<PermissionsConfig> {
512 self.permissions
513 .as_ref()
514 .and_then(|sp| sp.try_read().ok())
515 .map(|guard| guard.clone())
516 }
517
518 pub fn is_plan_mode(&self, tool_name: &str) -> bool {
521 self.permissions
522 .as_ref()
523 .and_then(|sp| sp.try_read().ok())
524 .map(|guard| guard.is_plan_mode(tool_name))
525 .unwrap_or(false)
526 }
527
528 pub fn with_touched_files(mut self, slot: Arc<Mutex<TouchedFiles>>) -> Self {
533 self.touched = Some(slot);
534 self
535 }
536
537 pub fn clear_touched_files(&mut self) {
539 self.touched = None;
540 }
541
542 pub fn touched_files(&self) -> Option<Arc<Mutex<TouchedFiles>>> {
544 self.touched.clone()
545 }
546
547 pub fn register(mut self, tool: Arc<dyn Tool>) -> Self {
548 let name = tool.spec().name;
549 self.tools.insert(name, tool);
550 self
551 }
552
553 pub fn register_with_aliases(mut self, tool: Arc<dyn Tool>, aliases: &[&str]) -> Self {
559 let name = tool.spec().name.clone();
560 for &alias in aliases {
561 self.aliases.insert(alias.to_string(), name.clone());
562 }
563 self.tools.insert(name, tool);
564 self
565 }
566
567 pub fn register_mut(&mut self, tool: Arc<dyn Tool>) {
569 let name = tool.spec().name;
570 self.tools.insert(name, tool);
571 }
572
573 pub fn register_mut_with_aliases(&mut self, tool: Arc<dyn Tool>, aliases: &[&str]) {
575 let name = tool.spec().name.clone();
576 for &alias in aliases {
577 self.aliases.insert(alias.to_string(), name.clone());
578 }
579 self.tools.insert(name, tool);
580 }
581
582 pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn Tool>> {
587 if let Some(tool) = self.tools.get(name) {
589 return Some(tool.clone());
590 }
591 if let Some(primary) = self.aliases.get(name) {
593 return self.tools.get(primary).cloned();
594 }
595 None
596 }
597
598 pub fn specs(&self) -> Vec<ToolSpec> {
599 self.tools.values().map(|t| t.spec()).collect()
600 }
601
602 pub fn names(&self) -> Vec<String> {
603 self.tools.keys().cloned().collect()
604 }
605
606 pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
607 self.tools.get(name).cloned()
608 }
609
610 pub fn is_readonly(&self, name: &str) -> bool {
612 self.tools
613 .get(name)
614 .map(|t| t.is_readonly())
615 .unwrap_or(false)
616 }
617
618 pub fn is_readonly_for_call(&self, name: &str, args: &Value) -> bool {
622 self.tools
623 .get(name)
624 .map(|t| t.is_readonly_for_args(args))
625 .unwrap_or(false)
626 }
627
628 pub async fn invoke(&self, name: &str, arguments: Value) -> Result<String> {
629 let effective_args = if let Some(hook) = &self.permission_hook {
632 match hook.check(name, &arguments).await {
633 PermissionDecision::Allow => arguments,
634 PermissionDecision::Transform(new_args) => new_args,
635 PermissionDecision::Deny(reason) => {
636 return Err(Error::PermissionDenied {
637 name: name.into(),
638 reason: DecisionReason::Hook { name: reason },
639 });
640 }
641 }
642 } else {
643 arguments
644 };
645 self.invoke_with_audit(name, effective_args).await.result
646 }
647
648 pub async fn invoke_with_audit(&self, name: &str, mut arguments: Value) -> ToolDispatch {
653 let safety_content = safety_content_for_tool(name, &arguments);
658 if let Some(ref sp) = self.permissions {
663 let guard = sp.read().await;
664
665 if matches!(guard.mode, PermissionMode::Auto) {
671 if let Some(ref classifier) = self.auto_classifier {
672 let args_summary =
673 serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".into());
674 let mut c = classifier.lock().await;
675 match c.classify(name, &args_summary, "").await {
676 Ok((true, _reason)) => {
677 if c.tracker.is_over_limit() {
678 return ToolDispatch {
679 result: Err(Error::PermissionDeniedLimit { name: name.into() }),
680 audit: AuditMeta::synthetic_unknown_tool(name),
681 };
682 }
683 return ToolDispatch {
684 result: Err(Error::PermissionDenied {
685 name: name.into(),
686 reason: DecisionReason::Mode(PermissionMode::Auto),
687 }),
688 audit: AuditMeta::synthetic_unknown_tool(name),
689 };
690 }
691 Ok((false, _)) => {
692 }
694 Err(_e) => {
695 }
697 }
698 }
699 }
702
703 let is_readonly = self.is_readonly(name);
704 let is_interactive_tool = guard.any_interactive(name);
707 let mut perm_is_unknown = false;
708 match guard.check_static(name, is_readonly, safety_content.as_deref()) {
709 Permission::Denied(reason, _msg) => {
710 return ToolDispatch {
711 result: Err(Error::PermissionDenied {
712 name: name.into(),
713 reason: reason.clone(),
714 }),
715 audit: AuditMeta::synthetic_unknown_tool(name),
716 };
717 }
718 Permission::Unknown => {
719 perm_is_unknown = true;
720 }
721 Permission::Allowed(_) => {}
722 }
723
724 if perm_is_unknown && !self.headless && is_interactive_tool {
731 if let Some(hook) = &self.permission_hook {
732 drop(guard);
733 match hook.check(name, &arguments).await {
734 PermissionDecision::Deny(reason) => {
735 return ToolDispatch {
736 result: Err(Error::PermissionDenied {
737 name: name.into(),
738 reason: DecisionReason::Hook { name: reason },
739 }),
740 audit: AuditMeta::synthetic_unknown_tool(name),
741 };
742 }
743 PermissionDecision::Transform(new_args) => {
744 arguments = new_args;
745 }
746 PermissionDecision::Allow => {}
747 }
748 } else {
750 drop(guard);
752 }
753 } else
754 if self.headless && is_interactive_tool {
756 if self.hook_runner.is_empty() {
758 return ToolDispatch {
759 result: Err(Error::PermissionDenied {
760 name: name.into(),
761 reason: DecisionReason::Hook {
762 name: "PermissionRequest".into(),
763 },
764 }),
765 audit: AuditMeta::synthetic_unknown_tool(name),
766 };
767 }
768 let hook_input = crate::hooks::external::HookInput {
769 event: crate::hooks::external::HookEvent::PermissionRequest,
770 tool_name: Some(name.to_string()),
771 args: Some(arguments.clone()),
772 mode: format!("{:?}", self.permission_mode),
773 content: None,
774 message: None,
775 depth: None,
776 reason: None,
777 error: None,
778 };
779 drop(guard);
782 let hook_result = self.hook_runner.dispatch(&hook_input).await;
783 if !matches!(hook_result.action, crate::hooks::HookAction::Continue) {
784 return ToolDispatch {
785 result: Err(Error::PermissionDenied {
786 name: name.into(),
787 reason: DecisionReason::Hook {
788 name: "PermissionRequest".into(),
789 },
790 }),
791 audit: AuditMeta::synthetic_unknown_tool(name),
792 };
793 }
794 } else {
795 drop(guard);
797 }
798 }
799
800 if let Some(slot) = &self.touched {
802 record_touched(name, &arguments, slot);
803 }
804
805 let Some(tool) = self.find_by_name(name) else {
806 return ToolDispatch {
807 result: Err(Error::UnknownTool(name.into())),
808 audit: AuditMeta::synthetic_unknown_tool(name),
809 };
810 };
811
812 let side_effect = tool.side_effect_class();
813 let step_id = uuid::Uuid::now_v7().hyphenated().to_string();
814 let args_hash = blake3_canonical_json(&arguments);
815 let started_at = unix_millis();
816
817 let args_size = arguments.to_string().len();
818 let span = tracing::info_span!("tool.execute", name = %name, args_size);
819 let raw_result = tool
820 .execute(arguments)
821 .instrument(span)
822 .await
823 .map_err(|e| match e {
824 Error::Tool { .. } | Error::BadToolArgs { .. } | Error::UnknownTool(_) => e,
825 other => Error::Tool {
826 name: name.into(),
827 message: other.to_string(),
828 },
829 });
830
831 let finished_at = unix_millis();
832 let exit_status = match &raw_result {
833 Ok(_) => ExitStatus::Ok,
834 Err(e) => {
835 let (clipped, truncated) = truncate_for_audit(&e.to_string());
836 ExitStatus::Err {
837 message: clipped,
838 truncated,
839 }
840 }
841 };
842
843 ToolDispatch {
844 result: raw_result,
845 audit: AuditMeta {
846 step_id,
847 started_at,
848 finished_at,
849 args_hash,
850 side_effect,
851 exit_status,
852 },
853 }
854 }
855}
856
857pub fn args_preview_for_permission(arguments: &Value) -> String {
860 let s = match arguments {
861 Value::Object(map) => {
862 let parts: Vec<String> = map
863 .iter()
864 .take(3)
865 .map(|(k, v)| {
866 let v_str = match v {
867 Value::String(s) => {
868 let short: String = s.chars().take(30).collect();
869 format!("\"{}\"", short)
870 }
871 other => {
872 let s = other.to_string();
873 s.chars().take(30).collect()
874 }
875 };
876 format!("{k}={v_str}")
877 })
878 .collect();
879 parts.join(", ")
880 }
881 other => other.to_string(),
882 };
883 if s.chars().count() > 80 {
884 let head: String = s.chars().take(79).collect();
885 format!("{head}…")
886 } else {
887 s
888 }
889}
890
891pub(crate) fn resolve_within(root: &std::path::Path, path: &str) -> Result<std::path::PathBuf> {
897 let candidate = std::path::Path::new(path);
898 let joined = if candidate.is_absolute() {
899 candidate.to_path_buf()
900 } else {
901 root.join(candidate)
902 };
903 let abs_root = absolutise(root);
904 let abs_joined = absolutise(&joined);
905 if !abs_joined.starts_with(&abs_root) {
906 return Err(Error::BadToolArgs {
907 name: "<fs>".into(),
908 message: format!(
909 "path `{}` escapes workspace root `{}`",
910 path,
911 abs_root.display()
912 ),
913 });
914 }
915 Ok(abs_joined)
916}
917
918fn absolutise(p: &std::path::Path) -> std::path::PathBuf {
921 let abs = if p.is_absolute() {
922 p.to_path_buf()
923 } else {
924 std::env::current_dir()
925 .unwrap_or_else(|_| std::path::PathBuf::from("."))
926 .join(p)
927 };
928 normalise(&abs)
929}
930
931fn normalise(p: &std::path::Path) -> std::path::PathBuf {
932 let mut out = std::path::PathBuf::new();
933 for c in p.components() {
934 use std::path::Component::*;
935 match c {
936 ParentDir => {
937 out.pop();
938 }
939 CurDir => {}
940 other => out.push(other.as_os_str()),
941 }
942 }
943 out
944}
945
946pub fn build_standard_tools(
955 workspace: &std::path::Path,
956 skills: &[crate::skills::Skill],
957 shell_timeout_secs: u64,
958) -> ToolRegistry {
959 let bg_manager = Arc::new(tokio::sync::Mutex::new(BackgroundJobManager::new()));
960 let todo_list = Arc::new(std::sync::RwLock::new(Vec::<TodoItem>::new()));
961 let mut registry = ToolRegistry::local()
962 .register(Arc::new(ReadFile::new(workspace)))
963 .register(Arc::new(WriteFile::new(workspace)))
964 .register(Arc::new(ApplyPatch::new(workspace)))
965 .register(Arc::new(StrReplaceTool::new(workspace)))
966 .register(Arc::new(ListDir::new(workspace)))
967 .register(Arc::new(
968 RunShell::new(workspace)
969 .with_timeout(std::time::Duration::from_secs(shell_timeout_secs)),
970 ))
971 .register(Arc::new(SearchFiles::new(workspace)))
972 .register(Arc::new(RunBackground::new(workspace, bg_manager.clone())))
973 .register(Arc::new(CheckBackground::new(bg_manager)))
974 .register(Arc::new(EstimateTokens::new(workspace)))
975 .register(Arc::new(Remember::new(workspace)))
976 .register(Arc::new(Recall::new(workspace)))
977 .register(Arc::new(Forget::new(workspace)))
978 .register(Arc::new(RememberFact::new(workspace)))
979 .register(Arc::new(RecallFact::new(workspace)))
980 .register(Arc::new(ForgetFact::new(workspace)))
981 .register(Arc::new(UpdateFact::new(workspace)))
982 .register(Arc::new(EpisodicRecall::new(workspace)))
983 .register(Arc::new(WorkingMemoryTool::new(workspace)))
984 .register(Arc::new(ScratchpadGet::new(workspace)))
985 .register(Arc::new(ScratchpadDelete::new(workspace)))
986 .register(Arc::new(ScratchpadList::new(workspace)))
987 .register(Arc::new(TodoWriteTool::new(
988 todo_list,
989 Arc::new(crate::event::NullSink),
990 )))
991 .register(Arc::new(A2aCallTool::new()))
992 .register(Arc::new(A2aCardTool::new()))
993 .register(Arc::new(A2aTaskCheckTool::new()));
994
995 #[cfg(feature = "web_fetch")]
1003 {
1004 registry = registry.register(Arc::new(WebFetch::new()));
1005 }
1006
1007 if !skills.is_empty() {
1008 registry = registry
1009 .register(Arc::new(LoadSkill::new(skills.to_vec())))
1010 .register(Arc::new(RunSkillScript::new(
1011 skills.to_vec(),
1012 workspace.to_path_buf(),
1013 std::time::Duration::from_secs(shell_timeout_secs),
1014 )));
1015 }
1016
1017 registry
1018}
1019
1020fn safety_content_for_tool(name: &str, args: &serde_json::Value) -> Option<String> {
1030 match name {
1031 "write_file" | "read_file" => args["path"].as_str().map(String::from),
1032 "apply_patch" => args["patch"].as_str().map(String::from),
1033 _ => None,
1034 }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039 use super::*;
1040 use crate::permissions::PermissionMode;
1041 use async_trait::async_trait;
1042
1043 struct Echo;
1044
1045 #[async_trait]
1046 impl Tool for Echo {
1047 fn spec(&self) -> ToolSpec {
1048 ToolSpec {
1049 name: "echo".into(),
1050 description: "echo".into(),
1051 parameters: serde_json::json!({"type":"object","properties":{"msg":{"type":"string"}}}),
1052 }
1053 }
1054 async fn execute(&self, args: Value) -> Result<String> {
1055 Ok(args["msg"].as_str().unwrap_or("").into())
1056 }
1057 }
1058
1059 #[tokio::test]
1060 async fn registry_dispatches_and_errors_on_unknown() {
1061 let reg = ToolRegistry::local().register(Arc::new(Echo));
1062 let out = reg
1063 .invoke("echo", serde_json::json!({"msg":"hi"}))
1064 .await
1065 .unwrap();
1066 assert_eq!(out, "hi");
1067 let err = reg.invoke("nope", serde_json::json!({})).await.unwrap_err();
1068 assert!(matches!(err, Error::UnknownTool(_)));
1069 }
1070
1071 #[test]
1072 fn resolve_within_rejects_escape() {
1073 let root = std::path::Path::new("/work");
1074 assert!(resolve_within(root, "../etc/passwd").is_err());
1075 assert!(resolve_within(root, "/elsewhere").is_err());
1076 assert!(resolve_within(root, "src/lib.rs").is_ok());
1077 }
1078
1079 #[test]
1080 fn resolve_within_handles_relative_root() {
1081 let cwd = std::env::current_dir().unwrap();
1083 let resolved = resolve_within(std::path::Path::new("."), "src/lib.rs").unwrap();
1084 assert!(resolved.starts_with(&cwd));
1085 assert!(resolved.ends_with("src/lib.rs"));
1086 }
1087
1088 #[tokio::test]
1089 async fn test_permission_deny_blocks_invoke() {
1090 let config = crate::permissions::LayeredPermissionsConfig {
1091 mode: PermissionMode::Default,
1092 layers: vec![crate::permissions::PermissionLayer {
1093 source: crate::permissions::RuleSource::User,
1094 allow: vec!["echo".into()],
1095 deny: vec!["echo".into()],
1096 ..Default::default()
1097 }],
1098 };
1099 let reg = ToolRegistry::local()
1100 .with_permissions(config)
1101 .register(Arc::new(Echo));
1102 let err = reg
1103 .invoke("echo", serde_json::json!({"msg":"hi"}))
1104 .await
1105 .unwrap_err();
1106 assert!(matches!(err, Error::PermissionDenied { .. }));
1107 }
1108
1109 struct AllowHook;
1112 struct DenyHook;
1113
1114 #[async_trait]
1115 impl PermissionHook for AllowHook {
1116 async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
1117 PermissionDecision::Allow
1118 }
1119 }
1120
1121 #[async_trait]
1122 impl PermissionHook for DenyHook {
1123 async fn check(&self, _name: &str, _args: &serde_json::Value) -> PermissionDecision {
1124 PermissionDecision::Deny("denied by test hook".to_string())
1125 }
1126 }
1127
1128 #[tokio::test]
1129 async fn permission_hook_allow_lets_tool_run() {
1130 let reg = ToolRegistry::local()
1131 .with_permission_hook(Arc::new(AllowHook))
1132 .register(Arc::new(Echo));
1133 let result = reg
1134 .invoke("echo", serde_json::json!({"msg": "hello"}))
1135 .await
1136 .unwrap();
1137 assert_eq!(result, "hello");
1138 }
1139
1140 #[tokio::test]
1141 async fn permission_hook_deny_blocks_invoke() {
1142 let reg = ToolRegistry::local()
1143 .with_permission_hook(Arc::new(DenyHook))
1144 .register(Arc::new(Echo));
1145 let err = reg
1146 .invoke("echo", serde_json::json!({"msg": "blocked"}))
1147 .await
1148 .unwrap_err();
1149 assert!(matches!(err, Error::PermissionDenied { .. }));
1150 }
1151
1152 #[test]
1153 fn args_preview_truncates_long_strings() {
1154 let big_val = "x".repeat(200);
1155 let args = serde_json::json!({"command": big_val});
1156 let preview = args_preview_for_permission(&args);
1157 assert!(preview.chars().count() <= 81); }
1159
1160 #[tokio::test]
1164 async fn headless_interactive_tool_denied_without_hooks() {
1165 let config = crate::permissions::LayeredPermissionsConfig {
1166 mode: PermissionMode::Default,
1167 layers: vec![crate::permissions::PermissionLayer {
1168 source: crate::permissions::RuleSource::User,
1169 interactive: vec!["echo".into()],
1170 ..Default::default()
1171 }],
1172 };
1173 let reg = ToolRegistry::local()
1174 .with_permissions(config)
1175 .with_headless(true)
1176 .register(Arc::new(Echo));
1177 let err = reg
1178 .invoke("echo", serde_json::json!({"msg": "hi"}))
1179 .await
1180 .unwrap_err();
1181 assert!(matches!(err, Error::PermissionDenied { .. }));
1182 }
1183
1184 #[tokio::test]
1186 async fn headless_interactive_tool_allowed_by_hook() {
1187 use tempfile::tempdir;
1188 let tmp = tempdir().unwrap();
1189 let hook_path = tmp.path().join("allow.sh");
1190 let script = "#!/bin/sh\nread -r _\necho '{\"action\":\"continue\"}'\n";
1191 std::fs::write(&hook_path, script).unwrap();
1192 #[cfg(unix)]
1193 {
1194 use std::os::unix::fs::PermissionsExt;
1195 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1196 perms.set_mode(0o755);
1197 std::fs::set_permissions(&hook_path, perms).unwrap();
1198 }
1199 let hook_runner = crate::hooks::ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1200
1201 let config = crate::permissions::LayeredPermissionsConfig {
1202 mode: PermissionMode::Default,
1203 layers: vec![crate::permissions::PermissionLayer {
1204 source: crate::permissions::RuleSource::User,
1205 interactive: vec!["echo".into()],
1206 ..Default::default()
1207 }],
1208 };
1209 let reg = ToolRegistry::local()
1210 .with_permissions(config)
1211 .with_headless(true)
1212 .with_hook_runner(hook_runner)
1213 .register(Arc::new(Echo));
1214
1215 #[cfg(unix)]
1216 {
1217 let result = reg
1218 .invoke("echo", serde_json::json!({"msg": "allowed"}))
1219 .await
1220 .unwrap();
1221 assert_eq!(result, "allowed");
1222 }
1223 #[cfg(not(unix))]
1224 {
1225 let err = reg
1226 .invoke("echo", serde_json::json!({"msg": "blocked"}))
1227 .await
1228 .unwrap_err();
1229 assert!(matches!(err, Error::PermissionDenied { .. }));
1230 }
1231 }
1232
1233 #[tokio::test]
1235 async fn non_headless_interactive_not_auto_denied() {
1236 let config = crate::permissions::LayeredPermissionsConfig {
1237 mode: PermissionMode::Default,
1238 layers: vec![crate::permissions::PermissionLayer {
1239 source: crate::permissions::RuleSource::User,
1240 interactive: vec!["echo".into()],
1241 ..Default::default()
1242 }],
1243 };
1244 let reg = ToolRegistry::local()
1245 .with_permissions(config)
1246 .with_headless(false)
1247 .register(Arc::new(Echo));
1248 let result = reg
1249 .invoke("echo", serde_json::json!({"msg": "hello"}))
1250 .await
1251 .unwrap();
1252 assert_eq!(result, "hello");
1253 }
1254
1255 #[test]
1259 fn permission_unknown_variant_exists() {
1260 use crate::permissions::Permission;
1261 let u = Permission::Unknown;
1262 assert!(!u.is_allowed());
1263 assert!(!u.is_denied());
1264 }
1265
1266 #[tokio::test]
1269 async fn unknown_interactive_tool_deny_hook_blocks_invoke_with_audit() {
1270 let config = crate::permissions::LayeredPermissionsConfig {
1271 mode: PermissionMode::Default,
1272 layers: vec![crate::permissions::PermissionLayer {
1273 source: crate::permissions::RuleSource::User,
1274 interactive: vec!["echo".into()],
1275 ..Default::default()
1276 }],
1277 };
1278 let reg = ToolRegistry::local()
1279 .with_permissions(config)
1280 .with_permission_hook(Arc::new(DenyHook))
1281 .with_headless(false)
1282 .register(Arc::new(Echo));
1283 let dispatch = reg
1284 .invoke_with_audit("echo", serde_json::json!({"msg": "hi"}))
1285 .await;
1286 assert!(
1287 matches!(dispatch.result, Err(Error::PermissionDenied { .. })),
1288 "hook-deny should block interactive Unknown tool via invoke_with_audit"
1289 );
1290 }
1291
1292 #[tokio::test]
1294 async fn unknown_interactive_tool_no_hook_is_allowed() {
1295 let config = crate::permissions::LayeredPermissionsConfig {
1296 mode: PermissionMode::Default,
1297 layers: vec![crate::permissions::PermissionLayer {
1298 source: crate::permissions::RuleSource::User,
1299 interactive: vec!["echo".into()],
1300 ..Default::default()
1301 }],
1302 };
1303 let reg = ToolRegistry::local()
1304 .with_permissions(config)
1305 .with_headless(false)
1306 .register(Arc::new(Echo));
1307 let dispatch = reg
1308 .invoke_with_audit("echo", serde_json::json!({"msg": "ok"}))
1309 .await;
1310 assert_eq!(
1311 dispatch.result.unwrap(),
1312 "ok",
1313 "no hook = allow for Unknown interactive tool"
1314 );
1315 }
1316
1317 #[tokio::test]
1319 async fn unknown_non_interactive_tool_hook_not_consulted() {
1320 let config = crate::permissions::LayeredPermissionsConfig {
1322 mode: PermissionMode::Default,
1323 layers: vec![],
1324 };
1325 let reg = ToolRegistry::local()
1326 .with_permissions(config)
1327 .with_permission_hook(Arc::new(DenyHook))
1328 .with_headless(false)
1329 .register(Arc::new(Echo));
1330 let dispatch = reg
1331 .invoke_with_audit("echo", serde_json::json!({"msg": "pass"}))
1332 .await;
1333 assert_eq!(
1334 dispatch.result.unwrap(),
1335 "pass",
1336 "DenyHook must not fire for non-interactive Unknown tools"
1337 );
1338 }
1339
1340 #[tokio::test]
1343 async fn allowed_interactive_tool_hook_not_consulted() {
1344 let config = crate::permissions::LayeredPermissionsConfig {
1345 mode: PermissionMode::Default,
1346 layers: vec![crate::permissions::PermissionLayer {
1347 source: crate::permissions::RuleSource::User,
1348 allow: vec!["echo".into()],
1349 interactive: vec!["echo".into()],
1350 ..Default::default()
1351 }],
1352 };
1353 let reg = ToolRegistry::local()
1354 .with_permissions(config)
1355 .with_permission_hook(Arc::new(DenyHook))
1356 .with_headless(false)
1357 .register(Arc::new(Echo));
1358 let dispatch = reg
1361 .invoke_with_audit("echo", serde_json::json!({"msg": "explicit-allow"}))
1362 .await;
1363 assert_eq!(
1364 dispatch.result.unwrap(),
1365 "explicit-allow",
1366 "Explicitly Allowed tools must not be re-checked via hook"
1367 );
1368 }
1369
1370 #[test]
1373 fn default_registry_has_no_plan_mode_tools() {
1374 let workspace = std::path::PathBuf::from(".");
1377 let registry = build_standard_tools(&workspace, &[], 30);
1378 assert!(
1379 registry.get("enter_plan_mode").is_none(),
1380 "enter_plan_mode must not be in the default registry"
1381 );
1382 assert!(
1383 registry.get("exit_plan_mode").is_none(),
1384 "exit_plan_mode must not be in the default registry"
1385 );
1386 }
1387}