Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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: |
Expand All @@ -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: |
Expand Down
6 changes: 3 additions & 3 deletions crates/language/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1721,17 +1721,17 @@ pub fn from_extension(path: &Path) -> Option<SupportLang> {
}

// 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);
}
}
Expand Down
77 changes: 75 additions & 2 deletions crates/services/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf> {
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 => {}
Comment on lines +152 to +161
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): Path validation can still be bypassed via symlinks inside base_path.

The current check only rejects .. segments and absolute paths, but a symlink within base_path can still point outside while starts_with passes because it uses the non-canonical path. To avoid this, canonicalize base_path once when constructing the context, canonicalize the resolved path before opening, and then enforce resolved_canonical.starts_with(base_canonical) so symlink escapes are prevented.

std::path::Component::ParentDir => {
if resolved == self.base_path {
// Attempted to traverse above base_path
return None;
}
resolved.pop();
Comment on lines +152 to +167
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "cannot traverse above base_path" check relies on resolved == self.base_path (line 163). If base_path is provided with non-normalized components (e.g., "foo/.", "foo/bar/.."), resolved can differ from the canonical base even when at the effective root, which can allow extra .. pops beyond the intended base. Canonicalize/normalize base_path once in new() (or store a separate normalized form) and compare against that normalized base during traversal checks.

Copilot uses AI. Check for mistakes.
}
std::path::Component::Normal(part) => {
resolved.push(part);
}
}
}

if resolved.starts_with(&self.base_path) {
Some(resolved)
Comment on lines +155 to +176
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

secure_path is only doing lexical component filtering. It does not prevent escaping base_path via symlinks (e.g., source = "subdir/link_to_/etc/passwd"), because read_to_string/write will follow symlinks. If untrusted users can influence the directory contents under base_path, this is still a path traversal vulnerability. Consider using an openat-style approach (dir FD + O_NOFOLLOW / capability-based FS such as cap-std), or explicitly rejecting symlinks while walking components and verifying the resolved/canonical path stays under a canonicalized base (noting TOCTOU concerns).

Copilot uses AI. Check for mistakes.
} else {
None
}
}
}

impl ExecutionContext for FileSystemContext {
fn read_content(&self, source: &str) -> Result<String, ServiceError> {
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
))
})?;
Comment on lines +185 to +200
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read_content/write_content both construct the same dynamic error string for invalid paths. Consider factoring this into a small helper (e.g., returning Result<PathBuf, ServiceError>) to avoid duplication and keep the error message consistent if it needs to change later.

Copilot uses AI. Check for mistakes.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
Expand Down Expand Up @@ -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")
);
}
}
Loading