返回 DeepSeek-TUI-2026
spec.rs
根目录 / crates / tui / src / tools / spec.rs
1 //! Tool specification traits for the DeepSeek TUI agent system.
2 //!
3 //! This module defines the core abstractions for tools:
4 //! - `ToolSpec`: The main trait that all tools must implement
5 //! - `ToolContext`: Execution context passed to tools
6 //! - `ToolResult`: Unified result type for tool execution
7 //! - `ToolCapability`: Capabilities and requirements of tools
8
9 use std::path::{Component, Path, PathBuf};
10 use std::sync::Arc;
11
12 use async_trait::async_trait;
13 use serde_json::Value;
14 use tokio_util::sync::CancellationToken;
15
16 use crate::features::Features;
17 use crate::lsp::LspManager;
18 use crate::network_policy::NetworkPolicyDecider;
19 use crate::sandbox::backend::SandboxBackend;
20 use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
21 #[allow(unused_imports)]
22 pub use deepseek_tools::{
23 ApprovalRequirement, ToolCapability, ToolError, ToolResult, optional_bool, optional_str,
24 optional_u64, required_str, required_u64,
25 };
26
27 /// Optional durable runtime services made available to model-visible tools.
28 ///
29 /// These are intentionally optional so existing unit tests and one-off tool
30 /// contexts keep working. Tools that need durable task/automation state fail
31 /// closed with a clear "not available" error when the relevant service is not
32 /// attached.
33 #[derive(Clone, Default)]
34 pub struct RuntimeToolServices {
35 pub shell_manager: Option<SharedShellManager>,
36 pub task_manager: Option<crate::task_manager::SharedTaskManager>,
37 pub automations: Option<crate::automation_manager::SharedAutomationManager>,
38 pub task_data_dir: Option<PathBuf>,
39 pub active_task_id: Option<String>,
40 pub active_thread_id: Option<String>,
41 /// Hook executor for `shell_env` injection (#456) and any future
42 /// tool-side hook events. `None` outside the live engine — test
43 /// contexts that don't care about hooks get a no-op.
44 pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
45 }
46
47 impl std::fmt::Debug for RuntimeToolServices {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.debug_struct("RuntimeToolServices")
50 .field("shell_manager", &self.shell_manager.is_some())
51 .field("task_manager", &self.task_manager.is_some())
52 .field("automations", &self.automations.is_some())
53 .field("task_data_dir", &self.task_data_dir)
54 .field("active_task_id", &self.active_task_id)
55 .field("active_thread_id", &self.active_thread_id)
56 .field("hook_executor", &self.hook_executor.is_some())
57 .finish()
58 }
59 }
60
61 /// Sandbox policy for command execution.
62 #[derive(Debug, Clone, Default)]
63 pub enum SandboxPolicy {
64 /// No sandboxing (dangerous but sometimes needed)
65 #[default]
66 None,
67 }
68
69 /// Context passed to tools during execution.
70 #[derive(Clone)]
71 pub struct ToolContext {
72 /// The workspace root directory
73 pub workspace: PathBuf,
74 /// Shared shell manager for background tasks and streaming IO.
75 pub shell_manager: SharedShellManager,
76 /// Whether to allow paths outside workspace
77 pub trust_mode: bool,
78 /// Current sandbox policy
79 #[allow(dead_code)]
80 pub sandbox_policy: SandboxPolicy,
81 /// Path for notes file
82 pub notes_path: PathBuf,
83 /// MCP configuration path
84 #[allow(dead_code)]
85 pub mcp_config_path: PathBuf,
86 /// Elevated sandbox policy override (used when retrying after sandbox denial).
87 /// This overrides the default sandbox behavior for shell commands.
88 pub elevated_sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
89 /// Whether tools should auto-approve without safety checks (YOLO mode).
90 /// When true, command safety analysis is skipped for shell execution.
91 pub auto_approve: bool,
92 /// Effective feature flag set for the running session.
93 pub features: Features,
94 /// Namespace for tool state that should be scoped to the current session/thread.
95 pub state_namespace: String,
96 /// User-trusted external paths the agent may read/write even when they
97 /// fall outside `workspace`. Loaded from `~/.deepseek/workspace-trust.json`
98 /// and refreshed when the user runs `/trust add <path>`. Distinct from
99 /// `trust_mode`, which is the all-or-nothing legacy switch (#29).
100 pub trusted_external_paths: Vec<PathBuf>,
101 /// Per-domain network policy (#135). When `None`, network tools fall back
102 /// to a permissive default that mirrors pre-v0.7.0 behavior so tests and
103 /// other contexts that don't construct a real policy keep working.
104 pub network_policy: Option<NetworkPolicyDecider>,
105 /// Durable runtime services for task, gate, PR-attempt, GitHub evidence,
106 /// and automation tools.
107 pub runtime: RuntimeToolServices,
108 /// Cancellation token for the active engine turn. Tools that may wait on
109 /// external work should observe this so UI cancel can interrupt them.
110 pub cancel_token: Option<CancellationToken>,
111 /// Optional external sandbox backend for shell execution.
112 /// When set, exec_shell routes commands through this instead of spawning
113 /// a local process.
114 pub sandbox_backend: Option<std::sync::Arc<dyn SandboxBackend>>,
115 /// Path to the user memory file. `None` when the user-memory feature
116 /// (#489) is disabled — tools that read or write the file should
117 /// short-circuit on `None` rather than fall back to a workspace-local
118 /// default.
119 pub memory_path: Option<PathBuf>,
120 /// LSP manager for post-edit diagnostics injection (#428). `None` when
121 /// LSP is disabled or the context is constructed in a test that does not
122 /// need diagnostics. Edit tools append a `<diagnostics>` block to their
123 /// result when this is present and the manager is enabled.
124 pub lsp_manager: Option<Arc<LspManager>>,
125
126 /// Large-output router (#548). When `Some`, tool results that exceed the
127 /// configured token threshold are routed through a V4-Flash synthesis
128 /// sub-agent before being returned to the parent context. `None` disables
129 /// routing (e.g. in sub-agents and test contexts to avoid recursion).
130 pub large_output_router: Option<crate::tools::large_output_router::LargeOutputRouter>,
131
132 /// Per-session workshop variable store (#548). Holds the raw content of
133 /// the most recent large-tool routing event so the parent can call
134 /// `promote_to_context` later. `None` when the router is disabled.
135 pub workshop_vars: Option<
136 std::sync::Arc<tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>>,
137 >,
138 }
139
140 impl ToolContext {
141 /// Create a new `ToolContext` with default settings.
142 #[must_use]
143 pub fn new(workspace: impl Into<PathBuf>) -> Self {
144 let workspace = workspace.into();
145 let shell_manager = new_shared_shell_manager(workspace.clone());
146 let notes_path = workspace.join(".deepseek").join("notes.md");
147 let mcp_config_path = workspace.join(".deepseek").join("mcp.json");
148 Self {
149 workspace,
150 shell_manager,
151 trust_mode: false,
152 sandbox_policy: SandboxPolicy::None,
153 notes_path,
154 mcp_config_path,
155 elevated_sandbox_policy: None,
156 auto_approve: false,
157 features: Features::with_defaults(),
158 state_namespace: "workspace".to_string(),
159 trusted_external_paths: Vec::new(),
160 network_policy: None,
161 runtime: RuntimeToolServices::default(),
162 cancel_token: None,
163 sandbox_backend: None,
164 memory_path: None,
165 lsp_manager: None,
166 large_output_router: None,
167 workshop_vars: None,
168 }
169 }
170
171 /// Create a `ToolContext` with all settings specified.
172 #[allow(dead_code)]
173 pub fn with_options(
174 workspace: impl Into<PathBuf>,
175 trust_mode: bool,
176 notes_path: impl Into<PathBuf>,
177 mcp_config_path: impl Into<PathBuf>,
178 ) -> Self {
179 let workspace = workspace.into();
180 let shell_manager = new_shared_shell_manager(workspace.clone());
181 Self {
182 workspace,
183 shell_manager,
184 trust_mode,
185 sandbox_policy: SandboxPolicy::None,
186 notes_path: notes_path.into(),
187 mcp_config_path: mcp_config_path.into(),
188 elevated_sandbox_policy: None,
189 auto_approve: false,
190 features: Features::with_defaults(),
191 state_namespace: "workspace".to_string(),
192 trusted_external_paths: Vec::new(),
193 network_policy: None,
194 runtime: RuntimeToolServices::default(),
195 cancel_token: None,
196 sandbox_backend: None,
197 memory_path: None,
198 lsp_manager: None,
199 large_output_router: None,
200 workshop_vars: None,
201 }
202 }
203
204 /// Create a `ToolContext` with auto-approve mode (YOLO).
205 pub fn with_auto_approve(
206 workspace: impl Into<PathBuf>,
207 trust_mode: bool,
208 notes_path: impl Into<PathBuf>,
209 mcp_config_path: impl Into<PathBuf>,
210 auto_approve: bool,
211 ) -> Self {
212 let workspace = workspace.into();
213 let shell_manager = new_shared_shell_manager(workspace.clone());
214 Self {
215 workspace,
216 shell_manager,
217 trust_mode,
218 sandbox_policy: SandboxPolicy::None,
219 notes_path: notes_path.into(),
220 mcp_config_path: mcp_config_path.into(),
221 elevated_sandbox_policy: None,
222 auto_approve,
223 features: Features::with_defaults(),
224 state_namespace: "workspace".to_string(),
225 trusted_external_paths: Vec::new(),
226 network_policy: None,
227 runtime: RuntimeToolServices::default(),
228 cancel_token: None,
229 sandbox_backend: None,
230 memory_path: None,
231 lsp_manager: None,
232 large_output_router: None,
233 workshop_vars: None,
234 }
235 }
236
237 /// Attach a per-domain network policy to this context (#135).
238 #[must_use]
239 pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
240 self.network_policy = Some(policy);
241 self
242 }
243
244 /// Attach durable runtime services to tools.
245 #[must_use]
246 pub fn with_runtime_services(mut self, runtime: RuntimeToolServices) -> Self {
247 self.runtime = runtime;
248 self
249 }
250
251 /// Attach the active engine cancellation token.
252 #[must_use]
253 pub fn with_cancel_token(mut self, cancel_token: CancellationToken) -> Self {
254 self.cancel_token = Some(cancel_token);
255 self
256 }
257
258 /// Attach an external sandbox backend for remote shell execution.
259 #[must_use]
260 #[allow(dead_code)]
261 pub fn with_sandbox_backend(mut self, backend: std::sync::Arc<dyn SandboxBackend>) -> Self {
262 self.sandbox_backend = Some(backend);
263 self
264 }
265
266 /// Set the user's trusted external paths (loaded from
267 /// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for
268 /// how the list is consulted.
269 #[must_use]
270 pub fn with_trusted_external_paths(mut self, paths: Vec<PathBuf>) -> Self {
271 self.trusted_external_paths = paths;
272 self
273 }
274
275 /// Attach an LSP manager so that edit tools can auto-inject diagnostics
276 /// into their results after a successful file modification (#428).
277 #[must_use]
278 #[allow(dead_code)]
279 pub fn with_lsp_manager(mut self, manager: Arc<LspManager>) -> Self {
280 self.lsp_manager = Some(manager);
281 self
282 }
283
284 /// Resolve a path relative to workspace, validating it doesn't escape.
285 ///
286 /// This handles both existing files (using canonicalize) and non-existent files
287 /// (for write operations) by canonicalizing the parent directory and appending
288 /// the filename.
289 /// Resolve a path relative to workspace, validating it doesn't escape.
290 ///
291 /// # Examples
292 ///
293 /// ```ignore
294 /// # use crate::tools::spec::ToolContext;
295 /// let ctx = ToolContext::new(".");
296 /// let path = ctx.resolve_path("README.md")?;
297 /// # Ok::<(), crate::tools::spec::ToolError>(())
298 /// ```
299 pub fn resolve_path(&self, raw: &str) -> Result<PathBuf, ToolError> {
300 let candidate = if std::path::Path::new(raw).is_absolute() {
301 PathBuf::from(raw)
302 } else {
303 self.workspace.join(raw)
304 };
305
306 // In trust mode, allow any path without validation
307 if self.trust_mode {
308 // Still try to canonicalize for consistency, but don't require it
309 return Ok(candidate.canonicalize().unwrap_or(candidate));
310 }
311
312 // Try to canonicalize the workspace
313 let workspace_canonical = self
314 .workspace
315 .canonicalize()
316 .unwrap_or_else(|_| self.workspace.clone());
317
318 // For the initial check, also try to canonicalize the candidate if possible
319 // This handles symlinks like /var -> /private/var on macOS
320 let candidate_canonical = candidate
321 .canonicalize()
322 .unwrap_or_else(|_| normalize_path(&candidate));
323 let workspace_normalized = normalize_path(&workspace_canonical);
324
325 // Check if the candidate is under the workspace (comparing canonical paths)
326 if !candidate_canonical.starts_with(&workspace_normalized) {
327 // Also try with non-canonical workspace for cases where workspace itself
328 // hasn't been canonicalized yet
329 let workspace_plain = normalize_path(&self.workspace);
330 let candidate_normalized = normalize_path(&candidate);
331 if !candidate_normalized.starts_with(&workspace_plain)
332 && !self.is_trusted_external_path(&candidate_canonical)
333 && !self.is_trusted_external_path(&candidate_normalized)
334 {
335 return Err(ToolError::PathEscape {
336 path: candidate_canonical,
337 });
338 }
339 }
340
341 // For existing paths, use canonicalize directly
342 if candidate.exists() {
343 let canonical = candidate.canonicalize().map_err(|e| {
344 ToolError::execution_failed(format!(
345 "Failed to canonicalize {}: {}",
346 candidate.display(),
347 e
348 ))
349 })?;
350
351 if !canonical.starts_with(&workspace_canonical)
352 && !self.is_trusted_external_path(&canonical)
353 {
354 return Err(ToolError::PathEscape { path: canonical });
355 }
356
357 return Ok(canonical);
358 }
359
360 // For non-existent paths (e.g., files to be created), validate via parent
361 // Find the deepest existing ancestor and canonicalize it
362 let mut existing_ancestor = candidate.clone();
363 let mut suffix_parts: Vec<std::ffi::OsString> = Vec::new();
364
365 while !existing_ancestor.exists() {
366 if let Some(file_name) = existing_ancestor.file_name() {
367 suffix_parts.push(file_name.to_owned());
368 }
369 match existing_ancestor.parent() {
370 Some(parent) if !parent.as_os_str().is_empty() => {
371 existing_ancestor = parent.to_path_buf();
372 }
373 _ => {
374 // No existing parent found; fall back to simple check
375 break;
376 }
377 }
378 }
379
380 let canonical_ancestor = if existing_ancestor.exists() {
381 existing_ancestor
382 .canonicalize()
383 .unwrap_or(existing_ancestor)
384 } else {
385 existing_ancestor
386 };
387
388 // Rebuild the full path from canonicalized ancestor
389 let mut canonical = canonical_ancestor;
390 for part in suffix_parts.into_iter().rev() {
391 canonical.push(part);
392 }
393 let canonical = normalize_path(&canonical);
394
395 // Validate it's under workspace, OR is under a user-trusted external
396 // path (`/trust add <path>` from the slash command, persisted in
397 // `~/.deepseek/workspace-trust.json`).
398 if !canonical.starts_with(&workspace_canonical)
399 && !canonical.starts_with(&workspace_normalized)
400 && !self.is_trusted_external_path(&canonical)
401 {
402 return Err(ToolError::PathEscape { path: canonical });
403 }
404
405 Ok(canonical)
406 }
407
408 /// Whether `path` is under any of the user-trusted external roots. The
409 /// caller should pass an already-canonicalized (or normalized) path.
410 fn is_trusted_external_path(&self, path: &Path) -> bool {
411 self.trusted_external_paths
412 .iter()
413 .any(|trusted| path.starts_with(trusted))
414 }
415
416 /// Set the trust mode.
417 #[allow(dead_code)]
418 pub fn with_trust_mode(mut self, trust: bool) -> Self {
419 self.trust_mode = trust;
420 self
421 }
422
423 /// Set the sandbox policy.
424 #[allow(dead_code)]
425 pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
426 self.sandbox_policy = policy;
427 self
428 }
429
430 /// Set feature flags for tool execution.
431 pub fn with_features(mut self, features: Features) -> Self {
432 self.features = features;
433 self
434 }
435
436 /// Override the shared shell manager.
437 pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
438 self.shell_manager = shell_manager;
439 self
440 }
441
442 /// Set the elevated sandbox policy override.
443 ///
444 /// This is used when retrying a tool after a sandbox denial, to run
445 /// with elevated permissions.
446 pub fn with_elevated_sandbox_policy(mut self, policy: crate::sandbox::SandboxPolicy) -> Self {
447 self.elevated_sandbox_policy = Some(policy);
448 self
449 }
450
451 /// Set the namespace used for session-scoped tool state.
452 pub fn with_state_namespace(mut self, namespace: impl Into<String>) -> Self {
453 self.state_namespace = namespace.into();
454 self
455 }
456
457 /// Attach the large-output router (#548). When set, tool results that
458 /// exceed the configured token threshold are synthesised by a V4-Flash
459 /// sub-agent before being returned to the parent context.
460 #[must_use]
461 pub fn with_large_output_router(
462 mut self,
463 router: crate::tools::large_output_router::LargeOutputRouter,
464 vars: std::sync::Arc<
465 tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>,
466 >,
467 ) -> Self {
468 self.large_output_router = Some(router);
469 self.workshop_vars = Some(vars);
470 self
471 }
472 }
473
474 /// Gather LSP diagnostics for `paths` using the manager stored in `context`,
475 /// and return the rendered `<diagnostics …>` blocks joined by newlines.
476 ///
477 /// Returns an empty string when:
478 /// - `context.lsp_manager` is `None`
479 /// - the manager's `enabled` flag is `false`
480 /// - none of the files produce diagnostics (e.g. all clean, or language unknown)
481 ///
482 /// This function is non-blocking by design: every failure mode (missing LSP
483 /// binary, timeout, unknown language) degrades to an empty string rather than
484 /// propagating an error to the caller.
485 pub async fn lsp_diagnostics_for_paths(context: &ToolContext, paths: &[PathBuf]) -> String {
486 use crate::lsp::render_blocks;
487
488 let manager = match context.lsp_manager.as_ref() {
489 Some(m) if m.config().enabled => m,
490 _ => return String::new(),
491 };
492
493 let mut blocks = Vec::new();
494 for (idx, path) in paths.iter().enumerate() {
495 if let Some(block) = manager.diagnostics_for(path, idx as u64).await {
496 blocks.push(block);
497 }
498 }
499
500 render_blocks(&blocks)
501 }
502
503 fn normalize_path(path: &Path) -> PathBuf {
504 let mut prefix: Option<std::ffi::OsString> = None;
505 let mut is_root = false;
506 let mut stack: Vec<std::ffi::OsString> = Vec::new();
507
508 for component in path.components() {
509 match component {
510 Component::Prefix(prefix_component) => {
511 prefix = Some(prefix_component.as_os_str().to_owned());
512 }
513 Component::RootDir => {
514 is_root = true;
515 }
516 Component::CurDir => {}
517 Component::ParentDir => {
518 let parent = Component::ParentDir.as_os_str();
519 if let Some(last) = stack.pop() {
520 if last == parent {
521 stack.push(last);
522 stack.push(parent.to_owned());
523 }
524 } else if !is_root {
525 stack.push(parent.to_owned());
526 }
527 }
528 Component::Normal(part) => {
529 stack.push(part.to_owned());
530 }
531 }
532 }
533
534 let mut normalized = PathBuf::new();
535 if let Some(prefix) = prefix {
536 normalized.push(prefix);
537 }
538 if is_root {
539 normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR));
540 }
541 for part in stack {
542 normalized.push(part);
543 }
544 normalized
545 }
546
547 /// The core trait that all tools must implement.
548 #[async_trait]
549 pub trait ToolSpec: Send + Sync {
550 /// Returns the unique name of this tool (used in API calls).
551 fn name(&self) -> &str;
552
553 /// Returns a human-readable description of what this tool does.
554 fn description(&self) -> &str;
555
556 /// Returns the JSON Schema for the tool's input parameters.
557 fn input_schema(&self) -> Value;
558
559 /// Returns the capabilities this tool has.
560 fn capabilities(&self) -> Vec<ToolCapability>;
561
562 /// Returns the approval requirement for this tool.
563 fn approval_requirement(&self) -> ApprovalRequirement {
564 let caps = self.capabilities();
565 if caps.contains(&ToolCapability::ExecutesCode) {
566 ApprovalRequirement::Required
567 } else if caps.contains(&ToolCapability::WritesFiles) {
568 ApprovalRequirement::Suggest
569 } else {
570 ApprovalRequirement::Auto
571 }
572 }
573
574 /// Returns whether this tool is sandboxable.
575 #[allow(dead_code)]
576 fn is_sandboxable(&self) -> bool {
577 self.capabilities().contains(&ToolCapability::Sandboxable)
578 }
579
580 /// Returns whether this tool is read-only.
581 fn is_read_only(&self) -> bool {
582 let caps = self.capabilities();
583 caps.contains(&ToolCapability::ReadOnly)
584 && !caps.contains(&ToolCapability::WritesFiles)
585 && !caps.contains(&ToolCapability::ExecutesCode)
586 }
587
588 /// Returns whether this tool can be executed in parallel with others.
589 fn supports_parallel(&self) -> bool {
590 false
591 }
592
593 /// Returns whether this tool should be excluded from the model-visible
594 /// tool catalog (deferred loading). Tools marked `true` are registered
595 /// but not sent to the model until explicitly activated via tool search.
596 fn defer_loading(&self) -> bool {
597 false
598 }
599
600 /// Execute the tool with the given input and context.
601 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError>;
602 }
603
604 // === Unit Tests ===
605
606 #[cfg(test)]
607 mod tests {
608 use super::*;
609 use serde_json::json;
610 use tempfile::tempdir;
611
612 #[test]
613 fn test_tool_result_success() {
614 let result = ToolResult::success("hello");
615 assert!(result.success);
616 assert_eq!(result.content, "hello");
617 assert!(result.metadata.is_none());
618 }
619
620 #[test]
621 fn test_tool_result_error() {
622 let result = ToolResult::error("something failed");
623 assert!(!result.success);
624 assert_eq!(result.content, "something failed");
625 }
626
627 #[test]
628 fn test_tool_result_json() {
629 let data = json!({"key": "value"});
630 let result = ToolResult::json(&data).unwrap();
631 assert!(result.success);
632 assert!(result.content.contains("key"));
633 }
634
635 #[test]
636 fn test_tool_result_with_metadata() {
637 let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
638 assert!(result.metadata.is_some());
639 }
640
641 #[test]
642 fn test_tool_context_resolve_path_relative() {
643 let tmp = tempdir().expect("tempdir");
644 let ctx = ToolContext::new(tmp.path().to_path_buf());
645
646 // Create a test file
647 let test_file = tmp.path().join("test.txt");
648 std::fs::write(&test_file, "test").expect("write");
649
650 let resolved = ctx.resolve_path("test.txt").expect("resolve");
651 assert!(resolved.ends_with("test.txt"));
652 }
653
654 #[test]
655 fn test_tool_context_resolve_path_escape() {
656 let tmp = tempdir().expect("tempdir");
657 let ctx = ToolContext::new(tmp.path().to_path_buf());
658
659 // Try to escape workspace
660 let result = ctx.resolve_path("/etc/passwd");
661 assert!(result.is_err());
662 }
663
664 #[test]
665 fn test_tool_context_resolve_path_parent_traversal() {
666 let tmp = tempdir().expect("tempdir");
667 let ctx = ToolContext::new(tmp.path().to_path_buf());
668
669 let result = ctx.resolve_path("../escape.txt");
670 assert!(result.is_err());
671 }
672
673 #[test]
674 fn test_tool_context_resolve_path_normalizes_parent() {
675 let tmp = tempdir().expect("tempdir");
676 let ctx = ToolContext::new(tmp.path().to_path_buf());
677
678 let result = ctx.resolve_path("new/../safe.txt");
679 assert!(result.is_ok());
680 }
681
682 #[test]
683 fn test_tool_context_trust_mode() {
684 let tmp = tempdir().expect("tempdir");
685 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
686
687 // In trust mode, absolute paths should work
688 let result = ctx.resolve_path("/tmp");
689 assert!(result.is_ok());
690 }
691
692 /// Issue #29: paths under a user-trusted external directory resolve
693 /// successfully even though they fall outside the workspace, while
694 /// untrusted external paths still error with `PathEscape`.
695 #[test]
696 fn test_tool_context_trusted_external_path_allows_escape() {
697 let workspace = tempdir().expect("workspace tempdir");
698 let trusted_root = tempdir().expect("trusted tempdir");
699 let trusted_file = trusted_root.path().join("notes.md");
700 std::fs::write(&trusted_file, "shared notes").unwrap();
701
702 let ctx =
703 ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
704 trusted_root
705 .path()
706 .canonicalize()
707 .unwrap_or_else(|_| trusted_root.path().to_path_buf()),
708 ]);
709
710 let resolved = ctx
711 .resolve_path(trusted_file.to_str().unwrap())
712 .expect("trusted path should resolve");
713 assert!(resolved.ends_with("notes.md"));
714
715 // Path outside workspace AND outside the trust list should still fail.
716 let other = tempdir().expect("untrusted tempdir");
717 let other_file = other.path().join("secret.md");
718 std::fs::write(&other_file, "x").unwrap();
719 let err = ctx
720 .resolve_path(other_file.to_str().unwrap())
721 .expect_err("untrusted path must error");
722 assert!(matches!(err, ToolError::PathEscape { .. }));
723 }
724
725 #[test]
726 fn test_required_str() {
727 let input = json!({"name": "test", "count": 42});
728 assert_eq!(required_str(&input, "name").unwrap(), "test");
729 assert!(required_str(&input, "missing").is_err());
730 assert!(required_str(&input, "count").is_err()); // not a string
731 }
732
733 #[test]
734 fn test_optional_str() {
735 let input = json!({"name": "test"});
736 assert_eq!(optional_str(&input, "name"), Some("test"));
737 assert_eq!(optional_str(&input, "missing"), None);
738 }
739
740 #[test]
741 fn test_required_u64() {
742 let input = json!({"count": 42});
743 assert_eq!(required_u64(&input, "count").unwrap(), 42);
744 assert!(required_u64(&input, "missing").is_err());
745 }
746
747 #[test]
748 fn test_optional_u64() {
749 let input = json!({"count": 42});
750 assert_eq!(optional_u64(&input, "count", 0), 42);
751 assert_eq!(optional_u64(&input, "missing", 100), 100);
752 }
753
754 #[test]
755 fn test_optional_bool() {
756 let input = json!({"flag": true});
757 assert!(optional_bool(&input, "flag", false));
758 assert!(!optional_bool(&input, "missing", false));
759 }
760
761 #[test]
762 fn test_tool_error_display() {
763 let err = ToolError::missing_field("path");
764 assert_eq!(
765 format!("{err}"),
766 "Failed to validate input: missing required field 'path'"
767 );
768
769 let err = ToolError::execution_failed("boom");
770 assert_eq!(format!("{err}"), "Failed to execute tool: boom");
771 }
772
773 #[test]
774 fn test_approval_requirement_default() {
775 let level = ApprovalRequirement::default();
776 assert_eq!(level, ApprovalRequirement::Auto);
777 }
778 }
779
779 lines RUST