Skip to main content

recursive/tools/
mod.rs

1//! Tool abstraction: any side effect the model can request.
2//!
3//! Tools are orthogonal to the agent and to each other. To add a capability
4//! you implement `Tool` and register it; no other file changes.
5
6// ── Sub-modules ────────────────────────────────────────────────────────────
7
8pub mod a2a;
9pub mod agent;
10pub mod agent_defs;
11pub mod audit;
12pub mod checkpoint;
13pub mod client_fs;
14pub mod count_lines;
15pub mod dispatch;
16#[cfg(feature = "cloud-runtime")]
17pub mod docker_provider;
18#[cfg(feature = "cloud-runtime")]
19pub mod docker_sandbox;
20#[cfg(feature = "e2b-sandbox")]
21pub mod e2b_provider;
22pub mod edit;
23pub mod episodic_recall;
24pub mod estimate_tokens;
25pub mod facts;
26pub mod fs;
27pub mod glob;
28#[cfg(feature = "skill-hub")]
29pub mod install_skill;
30pub mod load_skill;
31pub mod memory;
32pub mod permission_pipeline;
33pub mod plan_mode;
34pub mod policy;
35pub mod policy_sandbox;
36pub mod registry;
37pub mod run_background;
38pub mod schedule_wakeup;
39pub mod search;
40pub mod send_message;
41pub mod shell;
42pub mod stop_loop;
43#[cfg(feature = "coordinator-mode")]
44pub mod task_create;
45#[cfg(feature = "coordinator-mode")]
46pub mod task_get;
47#[cfg(feature = "coordinator-mode")]
48pub mod task_list;
49#[cfg(feature = "coordinator-mode")]
50pub mod task_output;
51#[cfg(feature = "coordinator-mode")]
52pub mod task_stop;
53#[cfg(feature = "coordinator-mode")]
54pub mod task_update;
55#[cfg(feature = "coordinator-mode")]
56pub mod team_create;
57#[cfg(feature = "coordinator-mode")]
58pub mod team_delete;
59pub mod todo;
60pub mod tool_search;
61pub mod transport;
62pub(crate) mod url_guard;
63pub mod watch_file;
64#[cfg(feature = "web_fetch")]
65pub mod web_fetch;
66#[cfg(feature = "web_search")]
67pub mod web_search;
68
69// ── Re-exports from registry ────────────────────────────────────────────────
70
71pub use registry::{
72    build_standard_tools, build_standard_tools_with_roots, PermissionHook, SpecWithHint, Tool,
73    ToolRegistry,
74};
75
76// ── Re-exports from audit ───────────────────────────────────────────────────
77
78pub use audit::{
79    AuditKey, AuditMeta, ExitStatus, ToolSideEffect, TouchedFiles, AUDIT_ERR_MAX_BYTES,
80};
81
82// ── Re-exports from dispatch ────────────────────────────────────────────────
83
84pub use dispatch::resolve_within;
85pub use dispatch::{args_preview_for_permission, ToolDispatch};
86pub use dispatch::{new_shared_sandbox_roots, resolve_within_any, AccessTier, SharedSandboxRoots};
87
88// ── Re-exports from individual tool modules ─────────────────────────────────
89
90pub use a2a::{A2aCallTool, A2aCardTool, A2aTaskCheckTool};
91pub use agent::{AgentTool, SharedMemoryRead, SharedMemoryWrite};
92pub use agent_defs::{AgentDefinition, AgentDefinitions};
93pub use checkpoint::{
94    build_checkpoint_save_tool, build_checkpoint_tools, CheckpointDiff, CheckpointList,
95    CheckpointSave, CheckpointSaveCtx, CheckpointToolCtx,
96};
97pub use client_fs::{AcpClientFsState, ClientReadFile, ClientWriteFile};
98pub use count_lines::CountLines;
99pub use edit::EditTool;
100pub use episodic_recall::{episodic_recall_summary, EpisodicRecall};
101pub use estimate_tokens::EstimateTokens;
102pub use facts::{
103    facts_path, facts_summary, load_facts, search_facts, Fact, FactStore, ForgetFact, RecallFact,
104    RememberFact, ScoredFact, UpdateFact,
105};
106pub use fs::{ReadFile, ReadFileState, WriteFile};
107pub use glob::GlobTool;
108#[cfg(feature = "skill-hub")]
109pub use install_skill::InstallSkill;
110pub use load_skill::LoadSkill;
111pub use memory::{
112    load_scratchpad, scratchpad_path, scratchpad_summary, Scratchpad, ScratchpadDelete,
113    ScratchpadGet, ScratchpadList, WorkingMemoryTool,
114};
115pub use memory::{Forget, Recall, Remember};
116pub use permission_pipeline::{CheckOutcome, PermissionPipeline};
117pub use plan_mode::{
118    EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanApprovalResult, PlanModeRequestGate,
119    PlanModeRequestResult, RequestPlanModeTool,
120};
121pub use policy_sandbox::{FsPolicy, PolicyConfig, ShellPolicy};
122pub use run_background::LoopControl;
123pub use run_background::{
124    BackgroundJobManager, CheckBackground, Job, JobState, RunBackground, WatchTarget,
125};
126pub use schedule_wakeup::{ScheduleWakeup, WakeupRequest, WakeupSlot};
127pub use search::SearchFiles;
128pub use send_message::{ListWorkersTool, SendMessageTool, WorkerMailbox, WorkerRegistry};
129pub use shell::RunShell;
130pub use stop_loop::StopLoop;
131pub use watch_file::WatchFile;
132
133#[cfg(feature = "coordinator-mode")]
134pub use task_create::TaskCreateTool;
135#[cfg(feature = "coordinator-mode")]
136pub use task_get::TaskGetTool;
137#[cfg(feature = "coordinator-mode")]
138pub use task_list::TaskListTool;
139#[cfg(feature = "coordinator-mode")]
140pub use task_output::TaskOutputTool;
141#[cfg(feature = "coordinator-mode")]
142pub use task_stop::TaskStopTool;
143#[cfg(feature = "coordinator-mode")]
144pub use task_update::TaskUpdateTool;
145#[cfg(feature = "coordinator-mode")]
146pub use team_create::TeamCreateTool;
147#[cfg(feature = "coordinator-mode")]
148pub use team_delete::TeamDeleteTool;
149pub use todo::{TodoItem, TodoStatus, TodoWriteTool};
150pub use tool_search::{DeferredCatalog, ToolSearchTool, TOOL_SEARCH_TOOL_NAME};
151pub use transport::{DirEntry, ExecResult, LocalTransport, ReadResult, ToolTransport};
152#[cfg(feature = "web_fetch")]
153pub use web_fetch::WebFetch;
154#[cfg(feature = "web_search")]
155pub use web_search::WebSearch;
156
157// ── Tests ───────────────────────────────────────────────────────────────────
158
159#[cfg(test)]
160mod tests {
161    use super::registry::{PermissionHook, Tool, ToolRegistry};
162    use crate::error::{Error, Result};
163    use crate::llm::ToolSpec;
164    use crate::permissions::PermissionMode;
165    use async_trait::async_trait;
166    use serde_json::Value;
167    use std::sync::Arc;
168
169    struct Echo;
170
171    #[async_trait]
172    impl Tool for Echo {
173        fn spec(&self) -> ToolSpec {
174            ToolSpec {
175                name: "echo".into(),
176                description: "echo".into(),
177                parameters: serde_json::json!({"type":"object","properties":{"msg":{"type":"string"}}}),
178            }
179        }
180        async fn execute(&self, args: Value) -> Result<String> {
181            Ok(args["msg"].as_str().unwrap_or("").into())
182        }
183    }
184
185    #[tokio::test]
186    async fn registry_dispatches_and_errors_on_unknown() {
187        let reg = ToolRegistry::local().register(Arc::new(Echo));
188        let out = reg
189            .invoke("echo", serde_json::json!({"msg":"hi"}))
190            .await
191            .unwrap();
192        assert_eq!(out, "hi");
193        let err = reg.invoke("nope", serde_json::json!({})).await.unwrap_err();
194        assert!(matches!(err, Error::UnknownTool(_)));
195    }
196
197    #[test]
198    fn resolve_within_rejects_escape() {
199        let root = std::path::Path::new("/work");
200        assert!(super::dispatch::resolve_within(root, "../etc/passwd").is_err());
201        assert!(super::dispatch::resolve_within(root, "/elsewhere").is_err());
202        assert!(super::dispatch::resolve_within(root, "src/lib.rs").is_ok());
203    }
204
205    #[test]
206    fn resolve_within_handles_relative_root() {
207        // Regression: `--workspace .` (relative) used to fail the prefix check.
208        let cwd = std::env::current_dir().unwrap();
209        let resolved =
210            super::dispatch::resolve_within(std::path::Path::new("."), "src/lib.rs").unwrap();
211        assert!(resolved.starts_with(&cwd));
212        assert!(resolved.ends_with("src/lib.rs"));
213    }
214
215    #[tokio::test]
216    async fn test_permission_deny_blocks_invoke() {
217        let config = crate::permissions::LayeredPermissionsConfig {
218            mode: PermissionMode::Default,
219            layers: vec![crate::permissions::PermissionLayer {
220                source: crate::permissions::RuleSource::User,
221                allow: vec!["echo".into()],
222                deny: vec!["echo".into()],
223                ..Default::default()
224            }],
225        };
226        let reg = ToolRegistry::local()
227            .with_permissions(config)
228            .register(Arc::new(Echo));
229        let err = reg
230            .invoke("echo", serde_json::json!({"msg":"hi"}))
231            .await
232            .unwrap_err();
233        assert!(matches!(err, Error::PermissionDenied { .. }));
234    }
235
236    // ── Goal-161: PermissionHook tests ───────────────────────────────────
237
238    struct AllowHook;
239    struct DenyHook;
240
241    #[async_trait]
242    impl PermissionHook for AllowHook {
243        async fn check(
244            &self,
245            _name: &str,
246            _args: &serde_json::Value,
247        ) -> crate::agent::PermissionDecision {
248            crate::agent::PermissionDecision::Allow
249        }
250    }
251
252    #[async_trait]
253    impl PermissionHook for DenyHook {
254        async fn check(
255            &self,
256            _name: &str,
257            _args: &serde_json::Value,
258        ) -> crate::agent::PermissionDecision {
259            crate::agent::PermissionDecision::Deny("denied by test hook".to_string())
260        }
261    }
262
263    #[tokio::test]
264    async fn permission_hook_allow_lets_tool_run() {
265        let reg = ToolRegistry::local()
266            .with_permission_hook(Arc::new(AllowHook))
267            .register(Arc::new(Echo));
268        let result = reg
269            .invoke("echo", serde_json::json!({"msg": "hello"}))
270            .await
271            .unwrap();
272        assert_eq!(result, "hello");
273    }
274
275    #[tokio::test]
276    async fn permission_hook_deny_blocks_invoke() {
277        let reg = ToolRegistry::local()
278            .with_permission_hook(Arc::new(DenyHook))
279            .register(Arc::new(Echo));
280        let err = reg
281            .invoke("echo", serde_json::json!({"msg": "blocked"}))
282            .await
283            .unwrap_err();
284        assert!(matches!(err, Error::PermissionDenied { .. }));
285    }
286
287    #[test]
288    fn args_preview_truncates_long_strings() {
289        let big_val = "x".repeat(200);
290        let args = serde_json::json!({"command": big_val});
291        let preview = super::dispatch::args_preview_for_permission(&args);
292        assert!(preview.chars().count() <= 81); // 80 chars + ellipsis
293    }
294
295    // ── Goal-199: Headless mode tests ────────────────────────────────────
296
297    /// headless=true, no hooks, interactive tool → PermissionDenied
298    #[tokio::test]
299    async fn headless_interactive_tool_denied_without_hooks() {
300        let config = crate::permissions::LayeredPermissionsConfig {
301            mode: PermissionMode::Default,
302            layers: vec![crate::permissions::PermissionLayer {
303                source: crate::permissions::RuleSource::User,
304                interactive: vec!["echo".into()],
305                ..Default::default()
306            }],
307        };
308        let reg = ToolRegistry::local()
309            .with_permissions(config)
310            .with_headless(true)
311            .register(Arc::new(Echo));
312        let err = reg
313            .invoke("echo", serde_json::json!({"msg": "hi"}))
314            .await
315            .unwrap_err();
316        assert!(matches!(err, Error::PermissionDenied { .. }));
317    }
318
319    /// headless=true, mock hook returns Continue → interactive tool allowed
320    #[tokio::test]
321    async fn headless_interactive_tool_allowed_by_hook() {
322        use tempfile::tempdir;
323        let tmp = tempdir().unwrap();
324        let hook_path = tmp.path().join("allow.sh");
325        let script = "#!/bin/sh\nread -r _\necho '{\"action\":\"continue\"}'\n";
326        std::fs::write(&hook_path, script).unwrap();
327        #[cfg(unix)]
328        {
329            use std::os::unix::fs::PermissionsExt;
330            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
331            perms.set_mode(0o755);
332            std::fs::set_permissions(&hook_path, perms).unwrap();
333        }
334        let hook_runner = crate::hooks::ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
335
336        let config = crate::permissions::LayeredPermissionsConfig {
337            mode: PermissionMode::Default,
338            layers: vec![crate::permissions::PermissionLayer {
339                source: crate::permissions::RuleSource::User,
340                interactive: vec!["echo".into()],
341                ..Default::default()
342            }],
343        };
344        let reg = ToolRegistry::local()
345            .with_permissions(config)
346            .with_headless(true)
347            .with_hook_runner(hook_runner)
348            .register(Arc::new(Echo));
349
350        #[cfg(unix)]
351        {
352            let result = reg
353                .invoke("echo", serde_json::json!({"msg": "allowed"}))
354                .await
355                .unwrap();
356            assert_eq!(result, "allowed");
357        }
358        #[cfg(not(unix))]
359        {
360            let err = reg
361                .invoke("echo", serde_json::json!({"msg": "blocked"}))
362                .await
363                .unwrap_err();
364            assert!(matches!(err, Error::PermissionDenied { .. }));
365        }
366    }
367
368    /// headless=false → interactive tools go through normal path (Passthrough)
369    #[tokio::test]
370    async fn non_headless_interactive_not_auto_denied() {
371        let config = crate::permissions::LayeredPermissionsConfig {
372            mode: PermissionMode::Default,
373            layers: vec![crate::permissions::PermissionLayer {
374                source: crate::permissions::RuleSource::User,
375                interactive: vec!["echo".into()],
376                ..Default::default()
377            }],
378        };
379        let reg = ToolRegistry::local()
380            .with_permissions(config)
381            .with_headless(false)
382            .register(Arc::new(Echo));
383        let result = reg
384            .invoke("echo", serde_json::json!({"msg": "hello"}))
385            .await
386            .unwrap();
387        assert_eq!(result, "hello");
388    }
389
390    // ── Goal-212: Permission::Unknown semantics ──────────────────────────────
391
392    /// Smoke test: Permission::Unknown variant compiles and can be constructed.
393    #[test]
394    fn permission_unknown_variant_exists() {
395        use crate::permissions::Permission;
396        let u = Permission::Unknown;
397        assert!(!u.is_allowed());
398        assert!(!u.is_denied());
399    }
400
401    /// Unknown + non-headless + interactive + DenyHook → PermissionDenied
402    /// (invoke_with_audit called directly, bypassing invoke()).
403    #[tokio::test]
404    async fn unknown_interactive_tool_deny_hook_blocks_invoke_with_audit() {
405        let config = crate::permissions::LayeredPermissionsConfig {
406            mode: PermissionMode::Default,
407            layers: vec![crate::permissions::PermissionLayer {
408                source: crate::permissions::RuleSource::User,
409                interactive: vec!["echo".into()],
410                ..Default::default()
411            }],
412        };
413        let reg = ToolRegistry::local()
414            .with_permissions(config)
415            .with_permission_hook(Arc::new(DenyHook))
416            .with_headless(false)
417            .register(Arc::new(Echo));
418        let dispatch = reg
419            .invoke_with_audit("echo", serde_json::json!({"msg": "hi"}))
420            .await;
421        assert!(
422            matches!(dispatch.result, Err(Error::PermissionDenied { .. })),
423            "hook-deny should block interactive Unknown tool via invoke_with_audit"
424        );
425    }
426
427    /// Unknown + non-headless + interactive + no hook → allowed (library default).
428    #[tokio::test]
429    async fn unknown_interactive_tool_no_hook_is_allowed() {
430        let config = crate::permissions::LayeredPermissionsConfig {
431            mode: PermissionMode::Default,
432            layers: vec![crate::permissions::PermissionLayer {
433                source: crate::permissions::RuleSource::User,
434                interactive: vec!["echo".into()],
435                ..Default::default()
436            }],
437        };
438        let reg = ToolRegistry::local()
439            .with_permissions(config)
440            .with_headless(false)
441            .register(Arc::new(Echo));
442        let dispatch = reg
443            .invoke_with_audit("echo", serde_json::json!({"msg": "ok"}))
444            .await;
445        assert_eq!(
446            dispatch.result.unwrap(),
447            "ok",
448            "no hook = allow for Unknown interactive tool"
449        );
450    }
451
452    /// Unknown + non-headless + non-interactive + DenyHook → allowed (hook not consulted).
453    #[tokio::test]
454    async fn unknown_non_interactive_tool_hook_not_consulted() {
455        // echo is NOT in the interactive list; DenyHook should not fire.
456        let config = crate::permissions::LayeredPermissionsConfig {
457            mode: PermissionMode::Default,
458            layers: vec![],
459        };
460        let reg = ToolRegistry::local()
461            .with_permissions(config)
462            .with_permission_hook(Arc::new(DenyHook))
463            .with_headless(false)
464            .register(Arc::new(Echo));
465        let dispatch = reg
466            .invoke_with_audit("echo", serde_json::json!({"msg": "pass"}))
467            .await;
468        assert_eq!(
469            dispatch.result.unwrap(),
470            "pass",
471            "DenyHook must not fire for non-interactive Unknown tools"
472        );
473    }
474
475    /// Allowed (explicit allow rule) + interactive + DenyHook
476    /// → allowed (no hook fired because perm_is_unknown=false).
477    #[tokio::test]
478    async fn allowed_interactive_tool_hook_not_consulted() {
479        let config = crate::permissions::LayeredPermissionsConfig {
480            mode: PermissionMode::Default,
481            layers: vec![crate::permissions::PermissionLayer {
482                source: crate::permissions::RuleSource::User,
483                allow: vec!["echo".into()],
484                interactive: vec!["echo".into()],
485                ..Default::default()
486            }],
487        };
488        let reg = ToolRegistry::local()
489            .with_permissions(config)
490            .with_permission_hook(Arc::new(DenyHook))
491            .with_headless(false)
492            .register(Arc::new(Echo));
493        // invoke_with_audit directly: check_static returns Allowed, not Unknown,
494        // so the Goal-212 hook block must NOT fire.
495        let dispatch = reg
496            .invoke_with_audit("echo", serde_json::json!({"msg": "explicit-allow"}))
497            .await;
498        assert_eq!(
499            dispatch.result.unwrap(),
500            "explicit-allow",
501            "Explicitly Allowed tools must not be re-checked via hook"
502        );
503    }
504
505    // ── Goal-201: plan mode tools are opt-in (not in default registry) ──────
506
507    #[test]
508    fn default_registry_has_no_plan_mode_tools() {
509        // build_standard_tools() must NOT register enter_plan_mode / exit_plan_mode.
510        // These are channel capabilities owned exclusively by AgentRuntimeBuilder.
511        let workspace = std::path::PathBuf::from(".");
512        let registry = super::registry::build_standard_tools(&workspace, &[], 30);
513        assert!(
514            registry.get("enter_plan_mode").is_none(),
515            "enter_plan_mode must not be in the default registry"
516        );
517        assert!(
518            registry.get("exit_plan_mode").is_none(),
519            "exit_plan_mode must not be in the default registry"
520        );
521    }
522
523    // ── Goal-247: ToolRegistry::fork() ──────────────────────────────────────
524
525    #[test]
526    fn fork_returns_usable_registry() {
527        let reg = ToolRegistry::local().register(Arc::new(Echo));
528        let forked = reg.fork();
529        // fork() should return a registry that can invoke tools
530        assert!(
531            forked.get("echo").is_some(),
532            "forked registry should contain 'echo'"
533        );
534        assert!(
535            forked.get("nope").is_none(),
536            "forked registry should not contain unknown tools"
537        );
538    }
539}