agent-workspace-adapter-local 0.3.0

Secure local filesystem Workspace adapter for Agent Infra
Documentation
use agent_workspace_contract::WORKSPACE_MAX_PATH_BYTES;
use anyhow::{Context, Result};
use std::path::{Component, Path, PathBuf};

/// One resolver shared by every local filesystem operation. It rejects
/// absolute paths, parent traversal and symlink components instead of relying
/// on operation-specific path checks.
pub(crate) struct WorkspacePathResolver<'a> {
    root: &'a Path,
}

impl<'a> WorkspacePathResolver<'a> {
    pub(crate) fn new(root: &'a Path) -> Self {
        Self { root }
    }

    pub(crate) fn lexical(&self, path: &str) -> Result<PathBuf> {
        let trimmed = path.trim();
        anyhow::ensure!(
            trimmed.len() <= WORKSPACE_MAX_PATH_BYTES,
            "workspace path exceeds {} bytes",
            WORKSPACE_MAX_PATH_BYTES
        );
        if trimmed.is_empty() || trimmed == "." {
            return Ok(self.root.to_path_buf());
        }
        let relative = Path::new(trimmed);
        anyhow::ensure!(!relative.is_absolute(), "workspace path must be relative");
        anyhow::ensure!(
            !relative.components().any(|component| matches!(
                component,
                Component::ParentDir | Component::RootDir | Component::Prefix(_)
            )),
            "workspace path must not contain parent/root components: {trimmed}"
        );
        Ok(normalize_path(&self.root.join(relative)))
    }

    pub(crate) async fn canonical_root(&self) -> Result<PathBuf> {
        tokio::fs::canonicalize(self.root)
            .await
            .with_context(|| format!("failed to resolve workspace root {}", self.root.display()))
    }

    async fn reject_symlink_components(&self, candidate: &Path) -> Result<()> {
        let relative = candidate
            .strip_prefix(self.root)
            .context("workspace path escaped root")?;
        let mut current = self.root.to_path_buf();
        for component in relative.components() {
            current.push(component.as_os_str());
            match tokio::fs::symlink_metadata(&current).await {
                Ok(metadata) => anyhow::ensure!(
                    !metadata.file_type().is_symlink(),
                    "workspace paths may not traverse symlinks: {}",
                    current.display()
                ),
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
                Err(error) => return Err(error.into()),
            }
        }
        Ok(())
    }

    pub(crate) async fn existing(&self, path: &str) -> Result<PathBuf> {
        let candidate = self.lexical(path)?;
        self.reject_symlink_components(&candidate).await?;
        let root = self.canonical_root().await?;
        let target = tokio::fs::canonicalize(&candidate)
            .await
            .with_context(|| format!("failed to resolve {}", candidate.display()))?;
        anyhow::ensure!(
            target.starts_with(root),
            "workspace path resolves outside root"
        );
        Ok(target)
    }

    #[cfg(not(unix))]
    pub(crate) async fn for_write(&self, path: &str) -> Result<PathBuf> {
        tokio::fs::create_dir_all(self.root).await?;
        let candidate = self.lexical(path)?;
        self.reject_symlink_components(&candidate).await?;
        let root = self.canonical_root().await?;
        let mut ancestor = candidate.as_path();
        while tokio::fs::symlink_metadata(ancestor).await.is_err() {
            ancestor = ancestor
                .parent()
                .context("workspace write path has no existing ancestor")?;
        }
        let canonical_ancestor = tokio::fs::canonicalize(ancestor).await?;
        anyhow::ensure!(
            canonical_ancestor.starts_with(root),
            "workspace write path resolves outside root"
        );
        Ok(candidate)
    }
}

pub(crate) fn normalize_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                normalized.pop();
            }
            other => normalized.push(other.as_os_str()),
        }
    }
    normalized
}