llm_coding_tools_core/path/
allowed.rs

1//! Allowed directory path resolver implementation.
2
3use super::PathResolver;
4use crate::error::{ToolError, ToolResult};
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8/// Path resolver that restricts access to allowed directories.
9///
10/// Paths are resolved relative to configured base directories.
11/// Prevents path traversal attacks by validating resolved paths
12/// stay within allowed boundaries.
13///
14/// # Security
15///
16/// This resolver protects against path traversal by:
17/// 1. Canonicalizing the resolved path to eliminate `..` and symlinks
18/// 2. Verifying the result starts with an allowed base directory
19///
20/// ## Bash Tool Bypasses Path Restrictions
21///
22/// **When the bash/shell tool is enabled, this resolver's protections are effectively
23/// advisory.** The bash tool permits arbitrary shell commands, meaning an LLM can
24/// directly read, write, or delete any file the process has OS-level permissions for
25/// (e.g., `cat /etc/passwd`, `rm -rf /`, `curl ... | sh`).
26///
27/// This resolver only restricts the structured file operations (`read`, `write`, `edit`,
28/// `glob`, `grep`). If your threat model requires actual filesystem sandboxing, you must
29/// either:
30///
31/// - Disable the bash tool entirely, or
32/// - Run the process in an OS-level sandbox (containers, seccomp, landlock, etc.)
33#[derive(Debug, Clone)]
34pub struct AllowedPathResolver {
35    /// Canonicalized allowed base directories.
36    allowed_paths: Arc<[PathBuf]>,
37}
38
39impl AllowedPathResolver {
40    /// Creates a new resolver with the given allowed directories.
41    ///
42    /// Each directory is canonicalized during construction to ensure
43    /// consistent path comparison. Returns an error if any directory
44    /// doesn't exist or can't be canonicalized.
45    pub fn new(allowed_paths: impl IntoIterator<Item = impl AsRef<Path>>) -> ToolResult<Self> {
46        let canonicalized: Result<Arc<[PathBuf]>, _> = allowed_paths
47            .into_iter()
48            .map(|p| {
49                let path = p.as_ref();
50                path.canonicalize().map_err(|e| {
51                    ToolError::InvalidPath(format!(
52                        "failed to canonicalize allowed path '{}': {}",
53                        path.display(),
54                        e
55                    ))
56                })
57            })
58            .collect();
59
60        Ok(Self {
61            allowed_paths: canonicalized?,
62        })
63    }
64
65    /// Creates a resolver from already-canonicalized paths.
66    ///
67    /// Use this when paths are known to be valid and canonicalized,
68    /// skipping the filesystem check.
69    ///
70    /// # Safety
71    ///
72    /// Caller must ensure paths are actually canonical. Using non-canonical
73    /// paths may allow path traversal attacks.
74    pub fn from_canonical(allowed_paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
75        Self {
76            allowed_paths: allowed_paths
77                .into_iter()
78                .map(|p| p.as_ref().to_path_buf())
79                .collect(),
80        }
81    }
82
83    /// Returns the allowed base directories.
84    pub fn allowed_paths(&self) -> &[PathBuf] {
85        &self.allowed_paths
86    }
87}
88
89impl PathResolver for AllowedPathResolver {
90    fn resolve(&self, path: &str) -> ToolResult<PathBuf> {
91        let input_path = PathBuf::from(path);
92
93        // Try each allowed base directory in order
94        for base in self.allowed_paths.iter() {
95            let candidate = base.join(&input_path);
96
97            // Try to canonicalize for existing paths
98            if let Ok(canonical) = candidate.canonicalize() {
99                // Security check: resolved path must stay within allowed base
100                if canonical.starts_with(base) {
101                    return Ok(canonical);
102                }
103                // Path escaped allowed directory - try next base
104                continue;
105            }
106
107            // For non-existent paths (write operations), validate parent
108            if let Some(parent) = candidate.parent() {
109                if let Ok(canonical_parent) = parent.canonicalize() {
110                    if canonical_parent.starts_with(base) {
111                        // Parent is valid, construct the final path
112                        let file_name = candidate.file_name().ok_or_else(|| {
113                            ToolError::InvalidPath("path has no file name".into())
114                        })?;
115                        return Ok(canonical_parent.join(file_name));
116                    }
117                }
118            }
119        }
120
121        Err(ToolError::InvalidPath(format!(
122            "path '{}' is not within allowed directories",
123            path
124        )))
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::fs;
132    use tempfile::TempDir;
133
134    fn setup_test_dir() -> TempDir {
135        let dir = TempDir::new().unwrap();
136        fs::create_dir_all(dir.path().join("subdir")).unwrap();
137        fs::write(dir.path().join("file.txt"), "content").unwrap();
138        fs::write(dir.path().join("subdir/nested.txt"), "nested").unwrap();
139        dir
140    }
141
142    #[test]
143    fn resolves_relative_path_in_allowed_dir() {
144        let dir = setup_test_dir();
145        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
146
147        let result = resolver.resolve("file.txt");
148        assert!(result.is_ok());
149        assert!(result.unwrap().ends_with("file.txt"));
150    }
151
152    #[test]
153    fn resolves_nested_path() {
154        let dir = setup_test_dir();
155        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
156
157        let result = resolver.resolve("subdir/nested.txt");
158        assert!(result.is_ok());
159    }
160
161    #[test]
162    fn rejects_path_traversal() {
163        let dir = setup_test_dir();
164        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
165
166        let result = resolver.resolve("../../../etc/passwd");
167        assert!(result.is_err());
168        assert!(result
169            .unwrap_err()
170            .to_string()
171            .contains("not within allowed"));
172    }
173
174    #[test]
175    fn allows_non_existent_path_for_write() {
176        let dir = setup_test_dir();
177        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
178
179        let result = resolver.resolve("new_file.txt");
180        assert!(result.is_ok());
181    }
182
183    #[test]
184    fn allows_nested_non_existent_path() {
185        let dir = setup_test_dir();
186        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
187
188        let result = resolver.resolve("subdir/new_file.txt");
189        assert!(result.is_ok());
190    }
191
192    #[test]
193    fn rejects_non_existent_path_outside_allowed() {
194        let dir = setup_test_dir();
195        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
196
197        // Parent traversal in non-existent path
198        let result = resolver.resolve("subdir/../../../new_file.txt");
199        assert!(result.is_err());
200    }
201
202    #[test]
203    fn tries_multiple_allowed_paths() {
204        let dir1 = setup_test_dir();
205        let dir2 = setup_test_dir();
206        fs::write(dir2.path().join("only_in_dir2.txt"), "content").unwrap();
207
208        let resolver =
209            AllowedPathResolver::new(vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()])
210                .unwrap();
211
212        // File only exists in dir2
213        let result = resolver.resolve("only_in_dir2.txt");
214        assert!(result.is_ok());
215    }
216
217    #[test]
218    fn returns_canonical_path() {
219        let dir = setup_test_dir();
220        let resolver = AllowedPathResolver::new(vec![dir.path().to_path_buf()]).unwrap();
221
222        let result = resolver.resolve("subdir/../file.txt");
223        assert!(result.is_ok());
224        // Should resolve to the canonical path without ../
225        let resolved = result.unwrap();
226        assert!(!resolved.to_string_lossy().contains(".."));
227    }
228}