Skip to main content

opi_coding_agent/tool/
mod.rs

1mod bash;
2mod edit;
3mod glob;
4mod grep;
5mod read;
6mod write;
7
8pub use bash::BashTool;
9pub use edit::EditTool;
10pub use glob::GlobTool;
11pub use grep::GrepTool;
12pub use read::ReadTool;
13pub use write::WriteTool;
14
15use std::path::{Path, PathBuf};
16
17/// Verify that `user_path` resolves within `workspace_root`. Returns the
18/// canonicalized file path on success, or an error message suitable for the
19/// tool response.
20///
21/// Walks up the ancestor chain to find the nearest existing directory, then
22/// canonicalizes that ancestor to resolve symlinks/junctions. This prevents
23/// an intermediate symlink pointing outside the workspace from bypassing the
24/// check when the deeper path components don't exist yet.
25pub fn validate_workspace_path(workspace_root: &Path, user_path: &str) -> Result<PathBuf, String> {
26    let resolved = workspace_root.join(user_path);
27    let canonical_root = std::fs::canonicalize(workspace_root)
28        .map_err(|e| format!("cannot canonicalize workspace root: {e}"))?;
29
30    // Try canonicalize first (handles symlinks, existing files).
31    if let Ok(canonical) = std::fs::canonicalize(&resolved) {
32        return if canonical.starts_with(&canonical_root) {
33            Ok(canonical)
34        } else {
35            Err(format!(
36                "path '{}' resolves outside the workspace",
37                user_path
38            ))
39        };
40    }
41
42    // Path doesn't exist — walk up ancestors to find the nearest existing one.
43    // Canonicalizing that ancestor resolves any symlinks/junctions in the chain.
44    // Components are pushed in reverse order (leaf first), then reversed.
45    let mut ancestor = resolved.as_path();
46    let mut suffix_components: Vec<std::ffi::OsString> = Vec::new();
47    while let Some(parent) = ancestor.parent() {
48        if let Some(name) = ancestor.file_name() {
49            suffix_components.push(name.to_os_string());
50        }
51        if let Ok(canonical_ancestor) = std::fs::canonicalize(parent) {
52            suffix_components.reverse();
53            let suffix: PathBuf = suffix_components.iter().collect();
54            let canonical = canonical_ancestor.join(suffix);
55            return if canonical.starts_with(&canonical_root) {
56                Ok(canonical)
57            } else {
58                Err(format!(
59                    "path '{}' resolves outside the workspace",
60                    user_path
61                ))
62            };
63        }
64        ancestor = parent;
65    }
66
67    // No ancestor exists on disk — normalize by resolving `..` components
68    // and check against the canonical root.
69    let normalized = normalize_path_components(&resolved);
70    if normalized.starts_with(&canonical_root) {
71        Ok(normalized)
72    } else {
73        Err(format!(
74            "path '{}' resolves outside the workspace",
75            user_path
76        ))
77    }
78}
79
80/// Resolve `.` and `..` components without touching the filesystem.
81fn normalize_path_components(path: &Path) -> PathBuf {
82    let mut stack = Vec::new();
83    for component in path.components() {
84        match component {
85            std::path::Component::ParentDir => {
86                stack.pop();
87            }
88            std::path::Component::CurDir => {}
89            c => stack.push(c.as_os_str()),
90        }
91    }
92    stack.iter().collect()
93}