Skip to main content

ai_agent/memdir/
team_mem_paths.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/memdir/teamMemPaths.ts
2//! Team memory paths and validation
3//!
4//! Provides team memory path resolution with security checks for path traversal
5//! and symlink attacks.
6
7use crate::memdir::paths::{get_auto_mem_path, is_auto_memory_enabled};
8use std::path::{Path, PathBuf};
9
10/// Error thrown when a path validation detects a traversal or injection attempt.
11#[derive(Debug, Clone)]
12pub struct PathTraversalError {
13    message: String,
14}
15
16impl std::fmt::Display for PathTraversalError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        write!(f, "{}", self.message)
19    }
20}
21
22impl std::error::Error for PathTraversalError {}
23
24impl PathTraversalError {
25    pub fn new(message: &str) -> Self {
26        Self {
27            message: message.to_string(),
28        }
29    }
30}
31
32/// Sanitize a file path key by rejecting dangerous patterns.
33/// Checks for null bytes, URL-encoded traversals, and other injection vectors.
34/// Returns the sanitized string or throws PathTraversalError.
35fn sanitize_path_key(key: &str) -> Result<String, PathTraversalError> {
36    // Null bytes can truncate paths in C-based syscalls
37    if key.contains('\0') {
38        return Err(PathTraversalError::new(&format!(
39            "Null byte in path key: \"{}\"",
40            key
41        )));
42    }
43
44    // URL-encoded traversals (e.g. %2e%2e%2f = ../)
45    // Simple check: decode percent-encoded sequences if possible
46    let mut has_url_encoded_traversal = false;
47    let key_lower = key.to_lowercase();
48    // Check for common URL-encoded patterns
49    if key_lower.contains("%2e%2e") || key_lower.contains("%2e/") || key_lower.contains("/%2e%2e") {
50        has_url_encoded_traversal = true;
51    }
52
53    if has_url_encoded_traversal {
54        return Err(PathTraversalError::new(&format!(
55            "URL-encoded traversal in path key: \"{}\"",
56            key
57        )));
58    }
59
60    // Reject backslashes (Windows path separator used as traversal vector)
61    if key.contains('\\') {
62        return Err(PathTraversalError::new(&format!(
63            "Backslash in path key: \"{}\"",
64            key
65        )));
66    }
67
68    // Reject absolute paths
69    if key.starts_with('/') {
70        return Err(PathTraversalError::new(&format!(
71            "Absolute path key: \"{}\"",
72            key
73        )));
74    }
75
76    Ok(key.to_string())
77}
78
79/// Whether team memory features are enabled.
80/// Team memory is a subdirectory of auto memory, so it requires auto memory
81/// to be enabled. This keeps all team-memory consumers (prompt, content
82/// injection, sync watcher, file detection) consistent when auto memory is
83/// disabled via env var or settings.
84pub fn is_team_memory_enabled() -> bool {
85    if !is_auto_memory_enabled() {
86        return false;
87    }
88
89    // TODO: integrate with growthbook feature flags
90    // getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false)
91    false
92}
93
94/// Returns the team memory path: <memoryBase>/projects/<sanitized-project-root>/memory/team/
95/// Lives as a subdirectory of the auto-memory directory, scoped per-project.
96pub fn get_team_mem_path() -> PathBuf {
97    let auto_mem = get_auto_mem_path();
98    let team_path = auto_mem.join("team");
99    let path_str = team_path.to_string_lossy().to_string();
100
101    // Ensure trailing separator and NFC normalization
102    let sep = std::path::MAIN_SEPARATOR;
103    if !path_str.ends_with(sep) {
104        format!("{}{}", path_str, sep).into()
105    } else {
106        team_path
107    }
108}
109
110/// Returns the team memory entrypoint: <memoryBase>/projects/<sanitized-project-root>/memory/team/MEMORY.md
111/// Lives as a subdirectory of the auto-memory directory, scoped per-project.
112pub fn get_team_mem_entypoint() -> PathBuf {
113    get_team_mem_path().join("MEMORY.md")
114}
115
116/// Check if a resolved absolute path is within the team memory directory.
117/// Uses path.resolve() to convert relative paths and eliminate traversal segments.
118/// Does NOT resolve symlinks — for write validation use validate_team_mem_write_path()
119/// or validate_team_mem_key() which include symlink resolution.
120pub fn is_team_mem_path(file_path: &Path) -> bool {
121    // SECURITY: resolve() converts to absolute and eliminates .. segments,
122    // preventing path traversal attacks (e.g. "team/../../etc/passwd")
123    let resolved_path = std::fs::canonicalize(file_path).unwrap_or_else(|_| {
124        // If canonicalize fails, fall back to resolving manually
125        if file_path.is_absolute() {
126            file_path.to_path_buf()
127        } else {
128            std::env::current_dir()
129                .map(|c| c.join(file_path))
130                .unwrap_or_else(|_| file_path.to_path_buf())
131        }
132    });
133
134    let team_dir = get_team_mem_path();
135    let team_dir_str = team_dir.to_string_lossy();
136
137    resolved_path.to_string_lossy().starts_with(&*team_dir_str)
138}
139
140/// Validate that an absolute file path is safe for writing to the team memory directory.
141/// Returns the resolved absolute path if valid.
142/// Throws PathTraversalError if the path contains injection vectors, escapes the
143/// directory via .. segments, or escapes via a symlink.
144pub fn validate_team_mem_write_path(file_path: &Path) -> Result<PathBuf, PathTraversalError> {
145    let path_str = file_path.to_string_lossy();
146    if path_str.contains('\0') {
147        return Err(PathTraversalError::new(&format!(
148            "Null byte in path: \"{}\"",
149            file_path.display()
150        )));
151    }
152
153    // First pass: normalize .. segments and check string-level containment.
154    // This is a fast rejection for obvious traversal attempts before we touch
155    // the filesystem.
156    let resolved_path = std::fs::canonicalize(file_path).unwrap_or_else(|_| {
157        if file_path.is_absolute() {
158            file_path.to_path_buf()
159        } else {
160            std::env::current_dir()
161                .map(|c| c.join(file_path))
162                .unwrap_or_else(|_| file_path.to_path_buf())
163        }
164    });
165
166    let team_dir = get_team_mem_path();
167    let team_dir_str = team_dir.to_string_lossy();
168
169    // Prefix attack protection: teamDir already ends with sep (from getTeamMemPath),
170    // so "team-evil/" won't match "team/"
171    if !resolved_path.to_string_lossy().starts_with(&*team_dir_str) {
172        return Err(PathTraversalError::new(&format!(
173            "Path escapes team memory directory: \"{}\"",
174            file_path.display()
175        )));
176    }
177
178    // TODO: Second pass - resolve symlinks on the deepest existing ancestor
179    // and verify the real path is still within the real team dir.
180
181    Ok(resolved_path)
182}
183
184/// Validate a relative path key from the server against the team memory directory.
185/// Sanitizes the key, joins with the team dir, resolves symlinks on the deepest
186/// existing ancestor, and verifies containment against the real team dir.
187/// Returns the resolved absolute path.
188/// Throws PathTraversalError if the key is malicious.
189pub fn validate_team_mem_key(relative_key: &str) -> Result<PathBuf, PathTraversalError> {
190    sanitize_path_key(relative_key)?;
191
192    let team_dir = get_team_mem_path();
193    let full_path = team_dir.join(relative_key);
194
195    // First pass: normalize .. segments and check string-level containment.
196    let resolved_path = std::fs::canonicalize(&full_path).unwrap_or_else(|_| full_path.clone());
197
198    let team_dir_str = team_dir.to_string_lossy();
199    if !resolved_path.to_string_lossy().starts_with(&*team_dir_str) {
200        return Err(PathTraversalError::new(&format!(
201            "Key escapes team memory directory: \"{}\"",
202            relative_key
203        )));
204    }
205
206    // TODO: Second pass - resolve symlinks and verify real containment.
207
208    Ok(resolved_path)
209}
210
211/// Check if a file path is within the team memory directory
212/// and team memory is enabled.
213pub fn is_team_mem_file(file_path: &Path) -> bool {
214    is_team_memory_enabled() && is_team_mem_path(file_path)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_sanitize_path_key_valid() {
223        assert!(sanitize_path_key("valid_name.md").is_ok());
224        assert!(sanitize_path_key("subdir/test.md").is_ok());
225    }
226
227    #[test]
228    fn test_sanitize_path_key_null_byte() {
229        let result = sanitize_path_key("test\0.md");
230        assert!(result.is_err());
231    }
232
233    #[test]
234    fn test_sanitize_path_key_absolute() {
235        let result = sanitize_path_key("/etc/passwd");
236        assert!(result.is_err());
237    }
238
239    #[test]
240    fn test_sanitize_path_key_backslash() {
241        let result = sanitize_path_key("test\\md");
242        assert!(result.is_err());
243    }
244
245    #[test]
246    fn test_is_team_memory_enabled_when_auto_disabled() {
247        // This test depends on environment, just verify the function runs
248        let _ = is_team_memory_enabled();
249    }
250}