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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 49 additions & 14 deletions crates/ai/src/acp/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ impl AthasAcpClient {
}
}

fn resolve_path(&self, path: &str) -> PathBuf {
fn resolve_path(&self, path: &str) -> Result<PathBuf, String> {
resolve_path_against_workspace(self.workspace_path.as_deref(), path)
.map_err(|e| format!("Path escapes the agent workspace: {}", e))
}

fn map_plan_priority(priority: acp::PlanEntryPriority) -> AcpPlanEntryPriority {
Expand Down Expand Up @@ -668,7 +669,10 @@ impl AthasAcpClient {
args: acp::ReadTextFileRequest,
) -> acp::Result<acp::ReadTextFileResponse> {
let path_str = args.path.to_string_lossy();
let path = self.resolve_path(&path_str);
let path = match self.resolve_path(&path_str) {
Ok(path) => path,
Err(message) => return Err(acp::Error::new(-32602, message)),
};
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
// Handle line and limit parameters for partial file reading
Expand Down Expand Up @@ -701,7 +705,10 @@ impl AthasAcpClient {
args: acp::WriteTextFileRequest,
) -> acp::Result<acp::WriteTextFileResponse> {
let path_str = args.path.to_string_lossy();
let path = self.resolve_path(&path_str);
let path = match self.resolve_path(&path_str) {
Ok(path) => path,
Err(message) => return Err(acp::Error::new(-32602, message)),
};

// Create parent directories if needed
if let Some(parent) = path.parent()
Expand Down Expand Up @@ -736,22 +743,50 @@ impl AthasAcpClient {
));
}

let working_dir = args
.cwd
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.or_else(|| self.workspace_path.as_deref().map(path_to_string));
let working_dir = match args.cwd.as_ref() {
Some(cwd) => {
let cwd_str = cwd.to_string_lossy();
match self.resolve_path(&cwd_str) {
Ok(path) => Some(path.to_string_lossy().to_string()),
Err(message) => return Err(acp::Error::new(-32602, message)),
}
}
None => self.workspace_path.as_deref().map(path_to_string),
};

// Agent-supplied environments must not smuggle loader or runtime
// options that execute code inside the spawned process.
let env_map: Option<HashMap<String, String>> = if args.env.is_empty() {
None
} else {
Some(
args
.env
let filtered: HashMap<String, String> = args
.env
.iter()
.filter(|e| {
let upper = e.name.to_ascii_uppercase();
let blocked = [
"PATH",
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"LD_AUDIT",
"NODE_OPTIONS",
"JAVA_TOOL_OPTIONS",
"JDK_JAVA_OPTIONS",
]
.iter()
.map(|e| (e.name.clone(), e.value.clone()))
.collect(),
)
.any(|key| upper == *key || upper.starts_with("LD_") || upper.starts_with("DYLD_"));
if blocked {
log::warn!("Dropping agent-supplied environment variable {}", e.name);
}
!blocked
})
.map(|e| (e.name.clone(), e.value.clone()))
.collect();
if filtered.is_empty() {
None
} else {
Some(filtered)
}
};
let command = args.command.clone();
let command_args = if args.args.is_empty() {
Expand Down
91 changes: 82 additions & 9 deletions crates/ai/src/acp/workspace_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,54 @@ pub(super) fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}

pub(super) fn resolve_path_against_workspace(workspace_path: Option<&Path>, path: &str) -> PathBuf {
pub(super) fn resolve_path_against_workspace(
workspace_path: Option<&Path>,
path: &str,
) -> Result<PathBuf> {
let candidate = PathBuf::from(path);
if candidate.is_absolute() {
return candidate;
let joined = if candidate.is_absolute() {
candidate
} else if let Some(workspace) = workspace_path {
workspace.join(candidate)
} else {
std::env::current_dir().unwrap_or_default().join(candidate)
};
let normalized = lexical_normalize(&joined);
let Some(workspace) = workspace_path else {
return Ok(normalized);
};

let workspace_normalized = lexical_normalize(workspace);
if !normalized.starts_with(&workspace_normalized) {
bail!("Path escapes the agent workspace");
}
enforce_no_symlink_escape(&workspace_normalized, &normalized)?;
Ok(normalized)
}

workspace_path
.map(|workspace| workspace.join(candidate.clone()))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join(candidate))
fn enforce_no_symlink_escape(workspace: &Path, path: &Path) -> Result<()> {
let canonical_workspace = fs::canonicalize(workspace)
.with_context(|| format!("Workspace path is not reachable: {}", workspace.display()))?;
let mut ancestor = path;
loop {
match fs::canonicalize(ancestor) {
Ok(canonical) => {
if !canonical.starts_with(&canonical_workspace) {
bail!("Path escapes the agent workspace through a symlink");
}
return Ok(());
}
Err(_) => {
let Some(parent) = ancestor.parent() else {
bail!("Path escapes the agent workspace");
};
if parent.as_os_str().is_empty() {
bail!("Path escapes the agent workspace");
}
ancestor = parent;
}
}
}
}

fn path_from_workspace_input(input: &str) -> Result<PathBuf> {
Expand Down Expand Up @@ -198,11 +237,45 @@ mod tests {

#[test]
fn resolves_relative_paths_against_workspace() {
let workspace = PathBuf::from("/workspace");
let temp_dir = tempfile::tempdir().unwrap();
let workspace = temp_dir.path().join("repo");
fs::create_dir(&workspace).unwrap();

assert_eq!(
resolve_path_against_workspace(Some(&workspace), "src/main.ts"),
PathBuf::from("/workspace/src/main.ts")
resolve_path_against_workspace(Some(&workspace), "src/main.ts").unwrap(),
workspace.join("src/main.ts")
);
}

#[test]
fn rejects_absolute_paths_outside_the_workspace() {
let temp_dir = tempfile::tempdir().unwrap();
let workspace = temp_dir.path().join("repo");
fs::create_dir(&workspace).unwrap();

let err = resolve_path_against_workspace(Some(&workspace), "/etc/passwd").unwrap_err();
assert!(err.to_string().contains("escapes the agent workspace"));
}

#[test]
fn rejects_parent_traversal_outside_the_workspace() {
let temp_dir = tempfile::tempdir().unwrap();
let workspace = temp_dir.path().join("repo");
fs::create_dir(&workspace).unwrap();

let err = resolve_path_against_workspace(Some(&workspace), "../outside.txt").unwrap_err();
assert!(err.to_string().contains("escapes the agent workspace"));
}

#[cfg(unix)]
#[test]
fn rejects_symlink_escape_from_the_workspace() {
let temp_dir = tempfile::tempdir().unwrap();
let workspace = temp_dir.path().join("repo");
fs::create_dir(&workspace).unwrap();
std::os::unix::fs::symlink("/etc", workspace.join("link")).unwrap();

let err = resolve_path_against_workspace(Some(&workspace), "link/passwd").unwrap_err();
assert!(err.to_string().contains("escapes the agent workspace"));
}
}
70 changes: 65 additions & 5 deletions crates/database/src/connection_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,29 +267,32 @@ fn network_connection_string(
return Ok(cs.clone());
}

let pass = password.unwrap_or_default();
// Percent-encode credentials so metacharacters such as @ : / ? #
// cannot shift the parsed host and send the password elsewhere.
let user = encode_url_userinfo(&config.username);
let pass = encode_url_userinfo(&password.unwrap_or_default());
match config.db_type.as_str() {
#[cfg(feature = "postgres")]
"postgres" => Ok(format!(
"postgres://{}:{}@{}:{}/{}",
config.username, pass, config.host, config.port, config.database
user, pass, config.host, config.port, config.database
)),
#[cfg(feature = "mysql")]
"mysql" => Ok(format!(
"mysql://{}:{}@{}:{}/{}",
config.username, pass, config.host, config.port, config.database
user, pass, config.host, config.port, config.database
)),
#[cfg(feature = "mongodb")]
"mongodb" => Ok(format!(
"mongodb://{}:{}@{}:{}/{}",
config.username, pass, config.host, config.port, config.database
user, pass, config.host, config.port, config.database
)),
#[cfg(feature = "redis")]
"redis" => {
if !config.username.is_empty() {
Ok(format!(
"redis://{}:{}@{}:{}",
config.username, pass, config.host, config.port
user, pass, config.host, config.port
))
} else if !pass.is_empty() {
Ok(format!("redis://:{}@{}:{}", pass, config.host, config.port))
Expand All @@ -300,3 +303,60 @@ fn network_connection_string(
_ => Err(format!("Unsupported database type: {}", config.db_type)),
}
}

#[cfg(any(
feature = "postgres",
feature = "mysql",
feature = "mongodb",
feature = "redis"
))]
fn encode_url_userinfo(input: &str) -> String {
const UNRESERVED: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
if UNRESERVED.contains(&byte) {
encoded.push(byte as char);
} else {
encoded.push('%');
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 15) as usize] as char);
}
}
encoded
}

#[cfg(all(test, feature = "postgres"))]
mod tests {
use super::*;

fn test_config() -> ConnectionConfig {
ConnectionConfig {
id: "test".to_string(),
name: "test".to_string(),
db_type: "postgres".to_string(),
host: "db.internal".to_string(),
port: 5432,
database: "app".to_string(),
username: "user@corp".to_string(),
connection_string: None,
}
}

#[test]
fn encodes_credential_metacharacters() {
let url = network_connection_string(&test_config(), Some("p@ss:w/rd?#".to_string())).unwrap();
assert_eq!(
url,
"postgres://user%40corp:p%40ss%3Aw%2Frd%3F%23@db.internal:5432/app"
);
}

#[test]
fn leaves_plain_credentials_untouched() {
let mut config = test_config();
config.username = "app".to_string();
let url = network_connection_string(&config, Some("s3cret".to_string())).unwrap();
assert_eq!(url, "postgres://app:s3cret@db.internal:5432/app");
}
}
4 changes: 4 additions & 0 deletions crates/debugger/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ fn spawn_exit_watcher(
}

fn read_protocol_message(reader: &mut impl BufRead) -> Result<Option<Value>> {
const MAX_PROTOCOL_FRAME_BYTES: usize = 64 * 1024 * 1024;
let mut content_length = None;
let mut line = String::new();

Expand All @@ -499,6 +500,9 @@ fn read_protocol_message(reader: &mut impl BufRead) -> Result<Option<Value>> {
}

let content_length = content_length.context("Debug adapter message missing Content-Length")?;
if content_length > MAX_PROTOCOL_FRAME_BYTES {
anyhow::bail!("Debug adapter sent an oversized protocol frame");
}
let mut content = vec![0u8; content_length];
reader.read_exact(&mut content)?;

Expand Down
3 changes: 2 additions & 1 deletion crates/extensions/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ linux = ["tauri/cef"]
anyhow = "1.0"
chrono = { version = "0.4.41", features = ["serde"] }
flate2 = "1.0"
futures-util = "0.3"
log = "0.4"
reqwest = "0.12"
reqwest = { version = "0.12", features = ["stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha256 = "1.5"
Expand Down
Loading