use anyhow::{Context, Result, ensure};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GrepMatch {
pub path: String,
pub line_number: usize,
pub line_content: String,
pub match_start: usize,
pub match_end: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
impl ExecResult {
#[must_use]
pub const fn success(&self) -> bool {
self.exit_code == 0
}
}
#[async_trait]
pub trait Environment: Send + Sync {
async fn read_file(&self, path: &str) -> Result<String>;
async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
async fn write_file(&self, path: &str, content: &str) -> Result<()>;
async fn write_file_bytes(&self, path: &str, content: &[u8]) -> Result<()>;
async fn list_dir(&self, path: &str) -> Result<Vec<FileEntry>>;
async fn exists(&self, path: &str) -> Result<bool>;
async fn is_dir(&self, path: &str) -> Result<bool>;
async fn is_file(&self, path: &str) -> Result<bool>;
async fn create_dir(&self, path: &str) -> Result<()>;
async fn delete_file(&self, path: &str) -> Result<()>;
async fn delete_dir(&self, path: &str, recursive: bool) -> Result<()>;
async fn grep(&self, pattern: &str, path: &str, recursive: bool) -> Result<Vec<GrepMatch>>;
async fn glob(&self, pattern: &str) -> Result<Vec<String>>;
async fn exec(&self, _command: &str, _timeout_ms: Option<u64>) -> Result<ExecResult> {
anyhow::bail!("Command execution not supported in this environment")
}
fn root(&self) -> &str;
fn resolve_path(&self, path: &str) -> String {
resolve_within_root(self.root(), path)
}
}
#[must_use]
pub fn resolve_within_root(root: &str, path: &str) -> String {
let root_norm = normalize_path_buf(Path::new(root));
let joined = if path.starts_with('/') {
PathBuf::from(path)
} else {
root_norm.join(path)
};
let normalized = normalize_path_buf(&joined);
if normalized == root_norm || normalized.starts_with(&root_norm) {
normalized.to_string_lossy().into_owned()
} else {
clamp_to_root(&root_norm, path)
.to_string_lossy()
.into_owned()
}
}
fn clamp_to_root(root_norm: &Path, path: &str) -> PathBuf {
let root_components: Vec<Component<'_>> = root_norm.components().collect();
let mut stack: Vec<Component<'_>> = root_components.clone();
for component in Path::new(path).components() {
match component {
Component::Prefix(_) | Component::RootDir | Component::CurDir => {}
Component::ParentDir => {
if stack.len() > root_components.len() {
stack.pop();
}
}
normal @ Component::Normal(_) => stack.push(normal),
}
}
stack.iter().collect()
}
#[must_use]
pub fn normalize_path(path: &Path) -> String {
normalize_path_buf(path).to_string_lossy().into_owned()
}
#[must_use]
pub fn normalize_path_buf(path: &Path) -> PathBuf {
let mut components: Vec<Component<'_>> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if matches!(components.last(), Some(Component::Normal(_))) {
components.pop();
}
}
Component::CurDir => {} other => components.push(other),
}
}
if components.is_empty() {
PathBuf::from("/")
} else {
components.iter().collect()
}
}
pub fn resolve_within_root_secure(root: &Path, path: &str) -> Result<PathBuf> {
let canonical_root = std::fs::canonicalize(root)
.with_context(|| format!("failed to canonicalize environment root {}", root.display()))?;
let clamped = clamp_to_root(&normalize_path_buf(root), path);
let resolved = canonicalize_deepest_existing(&clamped)?;
ensure!(
resolved == canonical_root || resolved.starts_with(&canonical_root),
"path {} escapes the environment root {} after resolving symlinks",
resolved.display(),
canonical_root.display(),
);
Ok(resolved)
}
fn canonicalize_deepest_existing(path: &Path) -> Result<PathBuf> {
let mut existing = path.to_path_buf();
let mut tail: Vec<OsString> = Vec::new();
while !existing.exists() {
let Some(name) = existing.file_name().map(ToOwned::to_owned) else {
break;
};
tail.push(name);
if !existing.pop() {
break;
}
}
let mut resolved = if existing.as_os_str().is_empty() {
PathBuf::from("/")
} else {
std::fs::canonicalize(&existing)
.with_context(|| format!("failed to canonicalize {}", existing.display()))?
};
for name in tail.into_iter().rev() {
resolved.push(name);
}
Ok(resolved)
}
pub struct NullEnvironment;
#[async_trait]
impl Environment for NullEnvironment {
async fn read_file(&self, _path: &str) -> Result<String> {
anyhow::bail!("No environment configured")
}
async fn read_file_bytes(&self, _path: &str) -> Result<Vec<u8>> {
anyhow::bail!("No environment configured")
}
async fn write_file(&self, _path: &str, _content: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn write_file_bytes(&self, _path: &str, _content: &[u8]) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn list_dir(&self, _path: &str) -> Result<Vec<FileEntry>> {
anyhow::bail!("No environment configured")
}
async fn exists(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn is_dir(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn is_file(&self, _path: &str) -> Result<bool> {
anyhow::bail!("No environment configured")
}
async fn create_dir(&self, _path: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn delete_file(&self, _path: &str) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn delete_dir(&self, _path: &str, _recursive: bool) -> Result<()> {
anyhow::bail!("No environment configured")
}
async fn grep(&self, _pattern: &str, _path: &str, _recursive: bool) -> Result<Vec<GrepMatch>> {
anyhow::bail!("No environment configured")
}
async fn glob(&self, _pattern: &str) -> Result<Vec<String>> {
anyhow::bail!("No environment configured")
}
fn root(&self) -> &'static str {
"/"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_resolves_parent_dir() {
let path = Path::new("/workspace/src/../../etc/passwd");
assert_eq!(normalize_path(path), "/etc/passwd");
}
#[test]
fn test_normalize_path_resolves_current_dir() {
let path = Path::new("/workspace/./src/./file.rs");
assert_eq!(normalize_path(path), "/workspace/src/file.rs");
}
#[test]
fn test_normalize_path_lexical_clamps_only_at_filesystem_root() {
let path = Path::new("/workspace/../../../etc/shadow");
assert_eq!(normalize_path(path), "/etc/shadow");
}
#[test]
fn test_normalize_path_identity() {
let path = Path::new("/workspace/src/main.rs");
assert_eq!(normalize_path(path), "/workspace/src/main.rs");
}
#[test]
fn test_normalize_path_clamps_at_root() {
let path = Path::new("/a/../../../../z");
assert_eq!(normalize_path(path), "/z");
}
#[test]
fn test_resolve_path_normalizes_traversal() {
let env = NullEnvironment;
let resolved = env.resolve_path("src/../../etc/passwd");
assert_eq!(resolved, "/etc/passwd");
}
#[test]
fn test_resolve_path_absolute_normalized() {
let env = NullEnvironment;
let resolved = env.resolve_path("/workspace/src/../../../etc/passwd");
assert_eq!(resolved, "/etc/passwd");
}
#[test]
fn resolve_within_root_keeps_paths_already_inside_root() {
assert_eq!(
resolve_within_root("/workspace", "/workspace/src/main.rs"),
"/workspace/src/main.rs"
);
assert_eq!(
resolve_within_root("/workspace", "src/main.rs"),
"/workspace/src/main.rs"
);
}
#[test]
fn resolve_within_root_clamps_parent_traversal() {
assert_eq!(
resolve_within_root("/workspace", "../../etc/passwd"),
"/workspace/etc/passwd"
);
assert_eq!(
resolve_within_root("/workspace", "src/../../../../etc/passwd"),
"/workspace/etc/passwd"
);
}
#[test]
fn resolve_within_root_clamps_absolute_escape() {
assert_eq!(
resolve_within_root("/workspace", "/etc/passwd"),
"/workspace/etc/passwd"
);
}
#[test]
fn resolve_within_root_does_not_confuse_sibling_prefixes() {
assert_eq!(
resolve_within_root("/workspace", "/workspace-evil/secret"),
"/workspace/workspace-evil/secret"
);
}
#[cfg(unix)]
#[test]
fn resolve_within_root_secure_rejects_symlink_escape() -> Result<()> {
use std::os::unix::fs::symlink;
let nanos = time::OffsetDateTime::now_utc().unix_timestamp_nanos();
let base =
std::env::temp_dir().join(format!("agent-sdk-secpath-{}-{nanos}", std::process::id()));
let root = base.join("workspace");
let outside = base.join("outside");
std::fs::create_dir_all(&root)?;
std::fs::create_dir_all(&outside)?;
std::fs::write(outside.join("secret.txt"), b"top secret")?;
symlink(&outside, root.join("link"))?;
let escape = resolve_within_root_secure(&root, "link/secret.txt");
assert!(
escape.is_err(),
"symlink escape must be rejected, got {escape:?}"
);
std::fs::write(root.join("inside.txt"), b"ok")?;
let inside = resolve_within_root_secure(&root, "inside.txt")?;
assert!(inside.starts_with(std::fs::canonicalize(&root)?));
let new_file = resolve_within_root_secure(&root, "subdir/new.txt")?;
assert!(new_file.starts_with(std::fs::canonicalize(&root)?));
let _ = std::fs::remove_dir_all(&base);
Ok(())
}
}