agent-workspace-adapter-local 0.3.0

Secure local filesystem Workspace adapter for Agent Infra
Documentation
#[cfg(test)]
mod local_tests {
    use super::super::*;
    use agent_workspace_contract::Workspace;

    #[tokio::test]
    async fn local_workspace_read_write_and_delete() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-test-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);

        ws.write_file("hello.txt", "world").await.unwrap();
        assert!(ws.exists("hello.txt").await.unwrap());
        assert_eq!(ws.read_file("hello.txt").await.unwrap(), "world");

        ws.remove_file("hello.txt").await.unwrap();
        assert!(!ws.exists("hello.txt").await.unwrap());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn local_workspace_list_dir() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-list-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("a.txt"), "a").unwrap();
        std::fs::write(root.join("b.txt"), "b").unwrap();
        std::fs::create_dir(root.join("sub")).unwrap();

        let ws = LocalWorkspace::new(root.clone());
        let entries = ws.list_dir(".").await.unwrap();
        assert_eq!(entries.len(), 3);
        assert_eq!(entries[0].name, "a.txt");
        assert_eq!(entries[1].name, "b.txt");
        assert_eq!(entries[2].name, "sub");
        assert!(entries[2].is_dir);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn local_workspace_exec() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-exec-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();

        let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
        let output = ws.exec("echo hello", None).await.unwrap();
        assert_eq!(output.exit_code, 0);
        assert!(output.stdout.trim().contains("hello"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn local_workspace_bounds_command_output() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-output-{}", uuid::Uuid::new_v4().simple()));
        std::fs::create_dir_all(&root).unwrap();
        let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
        let output = ws
            .exec(
                "python3 -c 'import sys; sys.stdout.write(\"x\" * 1100000)'",
                None,
            )
            .await
            .unwrap();

        assert_eq!(output.stdout.len(), 1024 * 1024);
        assert!(output.stdout_truncated);

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn conditional_writes_are_atomic_under_concurrency() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-cas-{}", uuid::Uuid::new_v4().simple()));
        let ws = LocalWorkspace::new(root.clone());
        ws.write_file("state.txt", "base").await.unwrap();
        let revision = ws.read_file_versioned("state.txt").await.unwrap().revision;
        assert!(revision.starts_with("sha256:"));
        assert_eq!(revision.len(), 71);
        assert!(ws
            .write_file_if_match("state.txt", b"legacy", Some("fnv1a64:0000000000000000"))
            .await
            .is_err());

        // Independent handles share no process-local mutex; the OS advisory
        // lock is the cross-process CAS boundary.
        let left_ws = LocalWorkspace::new(root.clone());
        let left_revision = revision.clone();
        let left = tokio::spawn(async move {
            left_ws
                .write_file_if_match("state.txt", b"left", Some(&left_revision))
                .await
        });
        let right_ws = LocalWorkspace::new(root.clone());
        let right = tokio::spawn(async move {
            right_ws
                .write_file_if_match("state.txt", b"right", Some(&revision))
                .await
        });
        let outcomes = [left.await.unwrap(), right.await.unwrap()];
        assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
        assert_eq!(outcomes.iter().filter(|result| result.is_err()).count(), 1);
        assert!(matches!(
            ws.read_file("state.txt").await.unwrap().as_str(),
            "left" | "right"
        ));
        assert!(std::fs::read_dir(&root).unwrap().all(|entry| !entry
            .unwrap()
            .file_name()
            .to_string_lossy()
            .contains(".agent-write-")));
        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn grep_rejects_results_above_the_total_output_budget() {
        let root = std::env::temp_dir().join(format!(
            "agent-ws-search-limit-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(
            root.join("large.txt"),
            format!("needle{}", "x".repeat(WORKSPACE_MAX_SEARCH_OUTPUT_BYTES)),
        )
        .unwrap();
        let ws = LocalWorkspace::new(root.clone());
        let error = ws.grep("needle", ".", None).await.unwrap_err();
        assert!(error.to_string().contains("output limit"));
        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn command_input_has_a_hard_limit() {
        let root = std::env::temp_dir().join(format!(
            "agent-ws-command-limit-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&root).unwrap();
        let ws = LocalWorkspace::new_with_host_exec(root.clone(), true);
        let command = "x".repeat(WORKSPACE_MAX_COMMAND_BYTES + 1);
        assert!(ws
            .exec(&command, None)
            .await
            .unwrap_err()
            .to_string()
            .contains("exceeds"));
        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn local_workspace_is_dir() {
        let root =
            std::env::temp_dir().join(format!("agent-ws-isdir-{}", uuid::Uuid::new_v4().simple()));
        let _ = std::fs::remove_dir_all(&root);
        std::fs::create_dir_all(&root).unwrap();
        std::fs::write(root.join("file.txt"), "x").unwrap();
        std::fs::create_dir(root.join("subdir")).unwrap();

        let ws = LocalWorkspace::new(root.clone());
        assert!(ws.is_dir("subdir").await.unwrap());
        assert!(!ws.is_dir("file.txt").await.unwrap());
        assert!(!ws.is_dir("nonexistent").await.unwrap());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn local_workspace_rejects_paths_outside_root() {
        let root = std::env::temp_dir().join(format!(
            "agent-ws-boundary-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&root).unwrap();
        let ws = LocalWorkspace::new(root.clone());

        assert!(ws.read_file("../outside.txt").await.is_err());
        assert!(ws.read_file("/etc/passwd").await.is_err());
        assert!(ws.write_file("../outside.txt", "bad").await.is_err());

        let _ = std::fs::remove_dir_all(&root);
    }

    #[tokio::test]
    async fn host_exec_is_disabled_by_default() {
        let root = std::env::temp_dir().join(format!(
            "agent-ws-no-exec-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&root).unwrap();
        let ws = LocalWorkspace::new(root.clone());

        let error = ws.exec("echo unsafe", None).await.unwrap_err();
        assert!(error.to_string().contains("disabled"));

        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn local_workspace_rejects_symlinks_outside_root() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join(format!(
            "agent-ws-symlink-{}",
            uuid::Uuid::new_v4().simple()
        ));
        let outside = std::env::temp_dir().join(format!(
            "agent-ws-outside-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(&root).unwrap();
        std::fs::create_dir_all(&outside).unwrap();
        std::fs::write(outside.join("secret.txt"), "secret").unwrap();
        symlink(&outside, root.join("escape")).unwrap();
        let ws = LocalWorkspace::new(root.clone());

        assert!(ws.read_file("escape/secret.txt").await.is_err());
        assert!(ws.write_file("escape/new.txt", "bad").await.is_err());

        let _ = std::fs::remove_dir_all(&root);
        let _ = std::fs::remove_dir_all(&outside);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn local_workspace_rejects_all_symlink_components() {
        use std::os::unix::fs::symlink;

        let root = std::env::temp_dir().join(format!(
            "agent-ws-symlink-component-{}",
            uuid::Uuid::new_v4().simple()
        ));
        std::fs::create_dir_all(root.join("real")).unwrap();
        std::fs::write(root.join("real/file.txt"), "content").unwrap();
        symlink(root.join("real"), root.join("alias")).unwrap();
        let ws = LocalWorkspace::new(root.clone());

        assert!(ws.read_file("alias/file.txt").await.is_err());
        assert!(ws.write_file("alias/new.txt", "bad").await.is_err());
        let _ = std::fs::remove_dir_all(&root);
    }

    #[cfg(unix)]
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn descriptor_relative_write_resists_parent_symlink_rename_race() {
        use std::os::unix::fs::symlink;
        use std::sync::{
            atomic::{AtomicBool, Ordering},
            Arc,
        };

        let root = tempfile::tempdir().unwrap();
        let outside = tempfile::tempdir().unwrap();
        std::fs::create_dir(root.path().join("safe")).unwrap();
        let stop = Arc::new(AtomicBool::new(false));
        let attacker_root = root.path().to_path_buf();
        let attacker_outside = outside.path().to_path_buf();
        let attacker_stop = stop.clone();
        let attacker = std::thread::spawn(move || {
            let safe = attacker_root.join("safe");
            let held = attacker_root.join("held");
            while !attacker_stop.load(Ordering::Relaxed) {
                if std::fs::rename(&safe, &held).is_ok() {
                    let _ = symlink(&attacker_outside, &safe);
                    let _ = std::fs::remove_file(&safe);
                    let _ = std::fs::rename(&held, &safe);
                }
            }
        });

        let workspace = LocalWorkspace::new(root.path().to_path_buf());
        for _ in 0..200 {
            let _ = workspace.write_file("safe/value.txt", "safe").await;
        }
        stop.store(true, Ordering::Relaxed);
        attacker.join().unwrap();
        assert!(
            !outside.path().join("value.txt").exists(),
            "descriptor-relative write must never follow a raced symlink"
        );
    }
}