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
6use 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// ── Goal-153: Tool side-effect classification + audit types ─────────────────
22
23/// Classification of a tool's observable side-effects on state outside
24/// the agent process. Used by orphan detection and safe-replay (g154) to
25/// decide how aggressively to retry or skip an unfinished tool call.
26///
27/// Distinct from `crate::kernel::SideEffect`, which tracks background-job
28/// scheduling; the two live in different modules and never collide.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ToolSideEffect {
32    /// No mutation of any state outside the agent process. Safe to
33    /// replay at any time. Examples: `read_file`, `search_files`,
34    /// `recall`, `checkpoint_list`.
35    ReadOnly,
36    /// Modifies local state (filesystem, scratchpad) in an idempotent-
37    /// friendly way. Examples: `write_file`, `apply_patch`, `remember`.
38    Mutating,
39    /// Reaches out to the external world or triggers opaque side-effects.
40    /// Cannot determine safe re-execution from local state alone.
41    /// Examples: `run_shell`, `sub_agent`, `schedule_wakeup`.
42    /// **Default** for any tool that does not override `side_effect_class`.
43    External,
44}
45
46/// Maximum length of the persisted error message in [`ExitStatus::Err`].
47/// Anything longer is UTF-8 char-boundary clipped and `truncated` is set.
48pub const AUDIT_ERR_MAX_BYTES: usize = 512;
49
50#[inline]
51fn is_false(b: &bool) -> bool {
52    !b
53}
54
55/// Outcome of a single tool invocation, as recorded in [`AuditMeta`].
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum ExitStatus {
59    Ok,
60    Err {
61        /// Error message, truncated to [`AUDIT_ERR_MAX_BYTES`] bytes.
62        message: String,
63        /// `true` when the original message was longer and was clipped.
64        #[serde(default, skip_serializing_if = "is_false")]
65        truncated: bool,
66    },
67}
68
69/// Per-call audit record returned by [`ToolRegistry::invoke_with_audit`]
70/// and stored in [`crate::session::TranscriptEntry::audit`].
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct AuditMeta {
73    /// UUIDv7 step identifier (time-ordered).
74    pub step_id: String,
75    /// Unix epoch millis at registry dispatch start.
76    pub started_at: i64,
77    /// Unix epoch millis when the tool returned.
78    pub finished_at: i64,
79    /// BLAKE3 of the canonical JSON of `arguments` (hex-encoded).
80    /// Detects argument drift across resumes.
81    pub args_hash: String,
82    /// Side-effect class as reported by the tool at call time.
83    pub side_effect: ToolSideEffect,
84    /// Whether the tool returned `Ok` or `Err`.
85    pub exit_status: ExitStatus,
86}
87
88impl AuditMeta {
89    /// Synthetic `AuditMeta` for an unknown-tool dispatch (tool not in
90    /// registry). Called when `invoke_with_audit` cannot find the tool.
91    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
107/// Return value of [`ToolRegistry::invoke_with_audit`]: the tool result
108/// and its accompanying audit record.
109pub struct ToolDispatch {
110    pub result: Result<String>,
111    pub audit: AuditMeta,
112}
113
114// ── helpers ─────────────────────────────────────────────────────────────────
115
116fn 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
123/// Clip `s` to at most `AUDIT_ERR_MAX_BYTES` bytes on a UTF-8 char boundary.
124/// Returns `(clipped, was_truncated)`.
125fn 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
136/// BLAKE3 hash of the canonical JSON encoding of `v`.
137fn 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    /// Classify this tool's observable side-effects. Default is the most
223    /// conservative value (`External`) so any unannotated tool is treated
224    /// as risky on resume. Override to `ReadOnly` or `Mutating` for
225    /// built-in tools; MCP tools derive this from their annotations.
226    fn side_effect_class(&self) -> ToolSideEffect {
227        ToolSideEffect::External
228    }
229
230    /// Convenience: a tool is read-only iff it classifies as `ReadOnly`.
231    /// Used by the parallel-dispatch path in `agent.rs`. Override only if
232    /// you have an unusual reason (you almost never should — override
233    /// `side_effect_class` instead and let this default through).
234    fn is_readonly(&self) -> bool {
235        matches!(self.side_effect_class(), ToolSideEffect::ReadOnly)
236    }
237
238    /// Like `is_readonly` but can inspect the call-time arguments.
239    ///
240    /// Override this when read-only-ness depends on parameters (e.g. `sub_agent`
241    /// with `subagent_type: "explore"` behaves as read-only while `"general_purpose"`
242    /// is not). The default delegates to `is_readonly()`.
243    fn is_readonly_for_args(&self, _arguments: &Value) -> bool {
244        self.is_readonly()
245    }
246}
247
248/// Goal-161: runtime permission hook. Implement this trait to intercept
249/// every tool invocation before it runs.
250///
251/// - [`PermissionDecision::Allow`] — let the call proceed unchanged.
252/// - [`PermissionDecision::Deny(reason)`] — block and return the reason as a tool error.
253/// - [`PermissionDecision::Transform(args)`] — replace the arguments before execution.
254///
255/// When no hook is registered all tools are allowed.
256#[async_trait]
257pub trait PermissionHook: Send + Sync {
258    /// Called before every tool dispatch.
259    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    /// Alias → primary name mapping for `find_by_name`.
266    /// Populated by `register`; never mutated by `invoke`.
267    aliases: BTreeMap<String, String>,
268    transport: Arc<dyn ToolTransport>,
269    /// Goal-197: thread-safe shared permissions for runtime rule updates.
270    /// When `Some`, `invoke_with_audit` reads through the lock at call time,
271    /// so `add_session_rule` / `remove_session_rule` changes are immediately
272    /// visible. When `None`, all tools are allowed (backward-compatible).
273    permissions: Option<SharedPermissions>,
274    /// Default permission mode for tools not covered by the config lists.
275    /// Mirrors `PermissionsConfig.mode` for quick access without config lookup.
276    permission_mode: PermissionMode,
277    touched: Option<Arc<Mutex<TouchedFiles>>>,
278    /// Goal-161: optional runtime permission hook. When `Some`, called
279    /// before every tool invocation. `None` means allow all (backward-
280    /// compatible default).
281    permission_hook: Option<Arc<dyn PermissionHook>>,
282    /// Goal-184: optional L1 policy config. Stored here so individual tools
283    /// can query it at call time. Does not enforce anything by itself;
284    /// tools must call `registry.policy()` and check before executing.
285    policy: Option<policy_sandbox::PolicyConfig>,
286    /// Goal-199: headless mode — interactive tools go through external hooks
287    /// instead of waiting for terminal input.
288    pub headless: bool,
289    /// Goal-199: external hook runner for headless permission checks.
290    pub hook_runner: crate::hooks::ExternalHookRunner,
291
292    /// Goal-200: optional auto classifier for `PermissionMode::Auto`.
293    /// When `Some`, each tool call in Auto mode is classified by the
294    /// LLM before execution. Wrapped in a `Mutex` (tokio) because `classify()`
295    /// takes `&mut self` (it updates the denial tracker).
296    pub auto_classifier: Option<Arc<tokio::sync::Mutex<AutoClassifier>>>,
297}
298
299/// Observer that records files touched by structured filesystem tools
300/// during a single agent turn. Owned by `AgentRuntime` and reset at
301/// every turn boundary; passed by `Arc<Mutex<...>>` to the
302/// `ToolRegistry` so tool dispatch can record `path` arguments.
303#[derive(Debug, Default, Clone)]
304pub struct TouchedFiles {
305    /// Workspace-relative file paths recorded from `write_file`,
306    /// `apply_patch`, etc.
307    pub paths: HashSet<String>,
308    /// True if the agent invoked `run_shell` this turn — runtime will
309    /// use a pre/post snapshot diff to attribute file changes.
310    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
327/// Inspect tool arguments for known fs tools and record their paths
328/// on the shared `TouchedFiles` collector.
329fn 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            // V4A patch headers carry the file paths. The agent passes
341            // the patch as a single string under "patch" or "input".
342            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    /// Create a registry with the default local transport.
380    pub fn local() -> Self {
381        Self::new(Arc::new(LocalTransport))
382    }
383
384    /// Returns a reference to the transport layer.
385    pub fn transport(&self) -> &Arc<dyn ToolTransport> {
386        &self.transport
387    }
388
389    /// Create a new empty registry that shares the same transport.
390    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    /// Attach a [`PermissionHook`] (Goal 161). When set, `ask_permission`
407    /// is called before every tool invocation; returning `false` causes
408    /// `invoke` to return `Error::PermissionDenied` without running the tool.
409    pub fn with_permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
410        self.permission_hook = Some(hook);
411        self
412    }
413
414    /// Attach a permission hook via mutable reference.
415    /// Equivalent to [`with_permission_hook`] but usable on existing registries.
416    pub fn set_permission_hook(&mut self, hook: Arc<dyn PermissionHook>) {
417        self.permission_hook = Some(hook);
418    }
419
420    /// Remove any previously attached permission hook.
421    pub fn clear_permission_hook(&mut self) {
422        self.permission_hook = None;
423    }
424
425    /// Attach an L1 policy config. The registry stores the policy so that
426    /// individual tools (e.g. `run_shell`) can query it via
427    /// `registry.policy()` at call time.
428    pub fn with_policy(mut self, policy: policy_sandbox::PolicyConfig) -> Self {
429        self.policy = Some(policy);
430        self
431    }
432
433    /// Set the L1 policy config via mutable reference.
434    pub fn set_policy(&mut self, policy: policy_sandbox::PolicyConfig) {
435        self.policy = Some(policy);
436    }
437
438    /// Return the attached policy config, if any.
439    pub fn policy(&self) -> Option<&policy_sandbox::PolicyConfig> {
440        self.policy.as_ref()
441    }
442
443    /// Enable headless mode (Goal 199): interactive tools go through external
444    /// hooks instead of waiting for terminal input.
445    pub fn with_headless(mut self, headless: bool) -> Self {
446        self.headless = headless;
447        self
448    }
449
450    /// Set headless mode via mutable reference.
451    pub fn set_headless(&mut self, headless: bool) {
452        self.headless = headless;
453    }
454
455    /// Attach an [`ExternalHookRunner`] for headless permission checks.
456    pub fn with_hook_runner(mut self, hook_runner: crate::hooks::ExternalHookRunner) -> Self {
457        self.hook_runner = hook_runner;
458        self
459    }
460
461    /// Set the external hook runner via mutable reference.
462    pub fn set_hook_runner(&mut self, hook_runner: crate::hooks::ExternalHookRunner) {
463        self.hook_runner = hook_runner;
464    }
465
466    /// Set the permissions configuration for this registry.
467    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    /// Attach a [`SharedPermissions`] reference for runtime rule updates.
474    ///
475    /// Unlike [`with_permissions`], this accepts an already-constructed
476    /// `Arc<RwLock<LayeredPermissionsConfig>>` so that multiple components
477    /// can share the same mutable config. Changes made via
478    /// `add_session_rule` / `remove_session_rule` on the shared config
479    /// are immediately visible through this registry.
480    pub fn with_shared_permissions(mut self, sp: SharedPermissions) -> Self {
481        // Snapshot the current mode for quick access.
482        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    /// Attach an [`AutoClassifier`] for `PermissionMode::Auto`.
490    ///
491    /// When the registry's permission mode is [`Auto`](PermissionMode::Auto),
492    /// each tool call is sent to the classifier before execution. The
493    /// classifier is wrapped in `Arc<Mutex<...>>` so it can be shared
494    /// across clones of the registry.
495    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    /// Return the current permission mode.
501    pub fn permission_mode(&self) -> PermissionMode {
502        self.permission_mode.clone()
503    }
504
505    /// Return a reference to the current permissions config, if any.
506    /// Return a cloned snapshot of the current permissions config.
507    ///
508    /// Uses `try_read()` — returns `None` if the lock is held for writing
509    /// (which is rare and brief). Callers that need a guaranteed read
510    /// should use [`invoke_with_audit`] which does an async `.read().await`.
511    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    /// Check whether a tool requires plan mode according to the current
519    /// permissions configuration.
520    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    /// Attach a [`TouchedFiles`] collector. Tool invocations on
529    /// structured filesystem tools will record their path arguments
530    /// onto the shared collector. Used by `AgentRuntime` to assemble
531    /// per-turn checkpoint metadata.
532    pub fn with_touched_files(mut self, slot: Arc<Mutex<TouchedFiles>>) -> Self {
533        self.touched = Some(slot);
534        self
535    }
536
537    /// Detach any previously attached collector.
538    pub fn clear_touched_files(&mut self) {
539        self.touched = None;
540    }
541
542    /// Return the currently attached touched-files collector, if any.
543    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    /// Register a tool and associate one or more aliases with it.
554    ///
555    /// Aliases are **not** sent to the LLM — they are only used by
556    /// [`find_by_name`] so sandboxed replacements can be looked up under
557    /// the original name the model knows.
558    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    /// Register a tool via mutable reference (for use with shared registries).
568    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    /// Register a tool with aliases via mutable reference.
574    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    /// Find a registered tool by its primary name or any alias.
583    ///
584    /// This is the preferred lookup path. `invoke` delegates to this so that
585    /// sandboxed tool replacements can be reached under the original name.
586    pub fn find_by_name(&self, name: &str) -> Option<Arc<dyn Tool>> {
587        // Fast path: primary name.
588        if let Some(tool) = self.tools.get(name) {
589            return Some(tool.clone());
590        }
591        // Alias path.
592        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    /// Check if a tool is read-only (no side effects).
611    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    /// Like `is_readonly` but passes call-time arguments to the tool so it can
619    /// make an argument-specific decision (e.g. `sub_agent` checking
620    /// `subagent_type: "explore"`).
621    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        // Goal-161: runtime permission hook — checked first, before static
630        // config, so the user gets the chance to allow/deny at call time.
631        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    /// Invoke a tool and return both its result and a populated
649    /// [`AuditMeta`]. Callers that need to persist audit data should
650    /// use this method; callers that don't can call `invoke` which
651    /// discards the audit half.
652    pub async fn invoke_with_audit(&self, name: &str, mut arguments: Value) -> ToolDispatch {
653        // Static permission check before any tool execution.
654        // Goal-196: extract the file-path "content" from arguments and
655        // pass it to `check_static` so the safety check (protected paths
656        // like `.git`, `.ssh`, `.env`) can fire on file tools.
657        let safety_content = safety_content_for_tool(name, &arguments);
658        // Goal-197: read through the shared permissions lock so that
659        // runtime session rules (add_session_rule / remove_session_rule)
660        // take effect immediately. The lock is held only for the
661        // permission check, not during tool execution.
662        if let Some(ref sp) = self.permissions {
663            let guard = sp.read().await;
664
665            // Goal-200: Auto mode — delegate to the LLM classifier before
666            // falling through to the static permission check. If the
667            // classifier blocks, return denial immediately. If the denial
668            // tracker is over limit, return a special error that the agent
669            // loop can use to set FinishReason::PermissionDenialLimit.
670            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                            // Allowed — fall through to static check.
693                        }
694                        Err(_e) => {
695                            // Classifier error — conservative: allow.
696                        }
697                    }
698                }
699                // If no classifier configured in Auto mode, fall through
700                // to static check (safe default).
701            }
702
703            let is_readonly = self.is_readonly(name);
704            // Pre-compute interactive flag before the match so we can use it
705            // after without holding `guard` across an await point.
706            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            // Goal-212: When no rule explicitly matched (Unknown) and the tool
725            // is in the interactive list, delegate to the registered
726            // PermissionHook as a safety net for non-headless callers (e.g.
727            // agent loop calling invoke_with_audit directly, bypassing
728            // invoke()). Headless interactive tools are handled below.
729            // Explicitly Allowed tools skip this check to avoid double-asking.
730            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                    // Hook allowed/transformed; guard already dropped, skip headless block.
749                } else {
750                    // No hook registered — non-headless library caller → allow.
751                    drop(guard);
752                }
753            } else
754            // Goal-199: headless mode — interactive tools go through external hooks.
755            if self.headless && is_interactive_tool {
756                // If no external hooks are registered → auto-deny.
757                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 the read guard before the async hook dispatch to
780                // avoid holding the lock across an await point.
781                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 before tool execution (not holding across await).
796                drop(guard);
797            }
798        }
799
800        // Record touched files for the active turn (if a collector is attached).
801        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
857/// Build a short human-readable preview of tool arguments for the
858/// permission dialog. Extracts up to 80 characters.
859pub 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
891/// Resolve a possibly-relative path against the workspace root.
892///
893/// Both the root and the candidate are normalised to an absolute, dot-free
894/// form before comparison so that `--workspace .` works exactly the same as
895/// `--workspace /abs/path`.
896pub(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
918/// Turn a path into an absolute, normalised form. Does not touch the disk,
919/// so it works for files that don't yet exist (needed by `write_file`).
920fn 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
946/// Build the standard tool registry for an agent rooted at `workspace`.
947///
948/// This is the canonical tool set shared by all entry points (CLI, TUI, HTTP
949/// server, etc.). Entry points may register additional tools on top of this
950/// baseline (e.g. `ScheduleWakeup` for loop mode, `SubAgent` when enabled).
951///
952/// Skills are opt-in: pass a non-empty `skills` slice to register
953/// `load_skill` and `run_skill_script`. Pass `&[]` to skip.
954pub 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    // Goal-201: plan mode tools are channel capabilities (TUI / HTTP only).
996    // They are registered exclusively by AgentRuntimeBuilder::build() which
997    // wires them to the real PlanApprovalGate and EventSink.  Headless /
998    // CLI / self-improve runs that call build_standard_tools() directly
999    // will not have these tools, preventing the LLM from blocking on an
1000    // interactive review that can never complete.
1001
1002    #[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
1020// ── Goal-196: Safety path content extraction ───────────────────────────────
1021
1022/// Extract the file-path "content" from tool arguments for the safety
1023/// check in `check_static`. Returns `None` for tools that don't operate
1024/// on a file path.
1025///
1026/// - `write_file` / `read_file`: extract `args["path"]`
1027/// - `apply_patch`: extract `args["patch"]` (the full V4A patch body)
1028/// - All other tools: `None`
1029fn 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        // Regression: `--workspace .` (relative) used to fail the prefix check.
1082        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    // ── Goal-161: PermissionHook tests ───────────────────────────────────
1110
1111    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); // 80 chars + ellipsis
1158    }
1159
1160    // ── Goal-199: Headless mode tests ────────────────────────────────────
1161
1162    /// headless=true, no hooks, interactive tool → PermissionDenied
1163    #[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    /// headless=true, mock hook returns Continue → interactive tool allowed
1185    #[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    /// headless=false → interactive tools go through normal path (Passthrough)
1234    #[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    // ── Goal-212: Permission::Unknown semantics ──────────────────────────────
1256
1257    /// Smoke test: Permission::Unknown variant compiles and can be constructed.
1258    #[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    /// Unknown + non-headless + interactive + DenyHook → PermissionDenied
1267    /// (invoke_with_audit called directly, bypassing invoke()).
1268    #[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    /// Unknown + non-headless + interactive + no hook → allowed (library default).
1293    #[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    /// Unknown + non-headless + non-interactive + DenyHook → allowed (hook not consulted).
1318    #[tokio::test]
1319    async fn unknown_non_interactive_tool_hook_not_consulted() {
1320        // echo is NOT in the interactive list; DenyHook should not fire.
1321        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    /// Allowed (explicit allow rule) + interactive + DenyHook
1341    /// → allowed (no hook fired because perm_is_unknown=false).
1342    #[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        // invoke_with_audit directly: check_static returns Allowed, not Unknown,
1359        // so the Goal-212 hook block must NOT fire.
1360        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    // ── Goal-201: plan mode tools are opt-in (not in default registry) ──────
1371
1372    #[test]
1373    fn default_registry_has_no_plan_mode_tools() {
1374        // build_standard_tools() must NOT register enter_plan_mode / exit_plan_mode.
1375        // These are channel capabilities owned exclusively by AgentRuntimeBuilder.
1376        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}