diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 029aad5..4b4f0f9 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -42,6 +42,7 @@ jobs: label_trigger: claude base_branch: staging max_turns: "30" + allowed_bots: "Copilot" allowed_tools: &allowed_tools | mcp__context7__resolve-library-id mcp__context7__get-library-docs @@ -111,6 +112,7 @@ jobs: label_trigger: claude base_branch: staging max_turns: "30" + allowed_bots: "Copilot" allowed_tools: *allowed_tools mcp_config: *mcp_config direct_prompt: | @@ -134,6 +136,7 @@ jobs: mode: agent base_branch: staging max_turns: "30" + allowed_bots: "Copilot" allowed_tools: *allowed_tools mcp_config: *mcp_config direct_prompt: | @@ -157,6 +160,7 @@ jobs: label_trigger: claude base_branch: staging max_turns: "30" + allowed_bots: "Copilot" allowed_tools: *allowed_tools mcp_config: *mcp_config direct_prompt: | diff --git a/crates/language/src/lib.rs b/crates/language/src/lib.rs index 721ddd6..7709c0e 100644 --- a/crates/language/src/lib.rs +++ b/crates/language/src/lib.rs @@ -1721,17 +1721,17 @@ pub fn from_extension(path: &Path) -> Option { } // Handle extensionless files or files with unknown extensions - if let Some(_file_name) = path.file_name().and_then(|n| n.to_str()) { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { // 1. Check if the full filename matches a known extension (e.g. .bashrc) #[cfg(any(feature = "bash", feature = "all-parsers"))] - if constants::BASH_EXTS.contains(&_file_name) { + if constants::BASH_EXTS.contains(&file_name) { return Some(SupportLang::Bash); } // 2. Check known extensionless file names #[cfg(any(feature = "bash", feature = "all-parsers", feature = "ruby"))] for (name, lang) in constants::LANG_RELATIONSHIPS_WITH_NO_EXTENSION { - if *name == _file_name { + if *name == file_name { return Some(*lang); } } diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index c932db7..3a9098e 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -147,16 +147,57 @@ impl FileSystemContext { base_path: base_path.as_ref().to_path_buf(), } } + + /// Safely resolves a path, preventing traversal attacks (e.g., "../../etc/passwd") + fn secure_path(&self, source: &str) -> Option { + let mut resolved = self.base_path.clone(); + + for component in std::path::Path::new(source).components() { + match component { + std::path::Component::Prefix(_) | std::path::Component::RootDir => { + // Absolute paths or prefixes are not allowed as they can break out + return None; + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if resolved == self.base_path { + // Attempted to traverse above base_path + return None; + } + resolved.pop(); + } + std::path::Component::Normal(part) => { + resolved.push(part); + } + } + } + + if resolved.starts_with(&self.base_path) { + Some(resolved) + } else { + None + } + } } impl ExecutionContext for FileSystemContext { fn read_content(&self, source: &str) -> Result { - let path = self.base_path.join(source); + let path = self.secure_path(source).ok_or_else(|| { + ServiceError::execution_dynamic(format!( + "Invalid path or directory traversal attempt: {}", + source + )) + })?; Ok(std::fs::read_to_string(path)?) } fn write_content(&self, destination: &str, content: &str) -> Result<(), ServiceError> { - let path = self.base_path.join(destination); + let path = self.secure_path(destination).ok_or_else(|| { + ServiceError::execution_dynamic(format!( + "Invalid path or directory traversal attempt: {}", + destination + )) + })?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -235,4 +276,36 @@ mod tests { let sources = ctx.list_sources().unwrap(); assert_eq!(sources, vec!["test.rs"]); } + + #[test] + fn test_filesystem_context_secure_path() { + let temp_dir = std::env::temp_dir(); + let ctx = FileSystemContext::new(&temp_dir); + + // Valid paths + assert!(ctx.secure_path("valid.txt").is_some()); + assert!(ctx.secure_path("subdir/valid.txt").is_some()); + assert!(ctx.secure_path("./valid.txt").is_some()); + + // Invalid paths (traversal attempts) + assert!(ctx.secure_path("../invalid.txt").is_none()); + assert!(ctx.secure_path("subdir/../../invalid.txt").is_none()); + assert!(ctx.secure_path("/etc/passwd").is_none()); + + // Ensure read_content returns an error + let read_err = ctx.read_content("../invalid.txt").unwrap_err(); + assert!( + read_err + .to_string() + .contains("Invalid path or directory traversal attempt") + ); + + // Ensure write_content returns an error + let write_err = ctx.write_content("../invalid.txt", "data").unwrap_err(); + assert!( + write_err + .to_string() + .contains("Invalid path or directory traversal attempt") + ); + } }