agent-file-tools 0.11.2

Agent File Tools — tree-sitter powered code analysis for AI agents
Documentation
use std::fs;
use std::path::Path;

use aft::config::Config;
use aft::context::AppContext;
use aft::language::StubProvider;

use super::helpers::AftProcess;

fn assert_error_code(resp: &serde_json::Value, code: &str) {
    assert_eq!(
        resp["success"], false,
        "expected failure response: {resp:?}"
    );
    assert_eq!(resp["code"], code, "unexpected error response: {resp:?}");
}

fn configure_restricted(aft: &mut AftProcess, root: &Path) {
    let configure = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "cfg",
            "command": "configure",
            "project_root": root.display().to_string(),
            "restrict_to_project_root": true,
        }))
        .unwrap(),
    );
    assert_eq!(
        configure["success"], true,
        "configure failed: {configure:?}"
    );
}

#[cfg(unix)]
fn create_dir_symlink(src: &Path, dst: &Path) {
    std::os::unix::fs::symlink(src, dst).expect("create symlink");
}

#[cfg(windows)]
fn create_dir_symlink(src: &Path, dst: &Path) {
    std::os::windows::fs::symlink_dir(src, dst).expect("create symlink");
}

#[test]
fn write_blocks_parent_dir_traversal_outside_project_root() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    let outside = dir.path().join("outside");
    fs::create_dir_all(&root).unwrap();
    fs::create_dir_all(&outside).unwrap();
    configure_restricted(&mut aft, &root);

    let attempted = root.join("../outside/escape.txt");
    let resp = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "write-parent-traversal",
            "command": "write",
            "file": attempted.display().to_string(),
            "content": "blocked",
        }))
        .unwrap(),
    );

    assert_error_code(&resp, "path_outside_root");
    assert!(!outside.join("escape.txt").exists());

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn read_blocks_absolute_path_outside_project_root() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    let outside = dir.path().join("outside.txt");
    fs::create_dir_all(&root).unwrap();
    fs::write(&outside, "secret").unwrap();
    configure_restricted(&mut aft, &root);

    let resp = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "read-outside-root",
            "command": "read",
            "file": outside.display().to_string(),
        }))
        .unwrap(),
    );

    assert_error_code(&resp, "path_outside_root");

    let status = aft.shutdown();
    assert!(status.success());
}

#[cfg(any(unix, windows))]
#[test]
fn write_blocks_symlink_traversal_outside_project_root() {
    let mut aft = AftProcess::spawn();
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    let outside = dir.path().join("outside");
    fs::create_dir_all(&root).unwrap();
    fs::create_dir_all(&outside).unwrap();
    create_dir_symlink(&outside, &root.join("link"));
    configure_restricted(&mut aft, &root);

    let attempted = root.join("link/newdir/escape.txt");
    let resp = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "write-symlink-traversal",
            "command": "write",
            "file": attempted.display().to_string(),
            "content": "blocked",
        }))
        .unwrap(),
    );

    assert_error_code(&resp, "path_outside_root");
    assert!(!outside.join("newdir/escape.txt").exists());

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn write_resolves_relative_dotdot_path_within_project_root() {
    let cwd = std::env::current_dir().unwrap();
    let dir = tempfile::tempdir_in(&cwd).unwrap();
    let root = dir.path().join("project");
    fs::create_dir_all(root.join("nested")).unwrap();

    let mut aft = AftProcess::spawn();
    configure_restricted(&mut aft, &root);

    let requested = root.join("nested/../resolved.txt");
    let relative_requested = requested.strip_prefix(&cwd).unwrap().to_path_buf();
    let resp = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "write-relative-dotdot",
            "command": "write",
            "file": relative_requested.display().to_string(),
            "content": "ok",
        }))
        .unwrap(),
    );

    assert_eq!(resp["success"], true, "write failed: {resp:?}");
    assert_eq!(fs::read_to_string(root.join("resolved.txt")).unwrap(), "ok");

    let status = aft.shutdown();
    assert!(status.success());
}

#[test]
fn validate_path_returns_canonical_path_that_write_uses() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path().join("project");
    fs::create_dir_all(root.join("nested")).unwrap();

    let requested = root.join("nested/../canonical.txt");
    let expected_root = std::fs::canonicalize(&root).unwrap_or(root.clone());
    let expected = expected_root.join("canonical.txt");

    let ctx = AppContext::new(
        Box::new(StubProvider),
        Config {
            project_root: Some(root.clone()),
            restrict_to_project_root: true,
            ..Config::default()
        },
    );

    let validated = match ctx.validate_path("validate-path", &requested) {
        Ok(path) => path,
        Err(resp) => panic!("validate_path failed unexpectedly: {resp:?}"),
    };
    assert_eq!(validated, expected);

    let mut aft = AftProcess::spawn();
    configure_restricted(&mut aft, &root);
    let resp = aft.send(
        &serde_json::to_string(&serde_json::json!({
            "id": "write-canonical-path",
            "command": "write",
            "file": requested.display().to_string(),
            "content": "canonical",
        }))
        .unwrap(),
    );

    assert_eq!(resp["success"], true, "write failed: {resp:?}");
    assert_eq!(fs::read_to_string(&validated).unwrap(), "canonical");

    let status = aft.shutdown();
    assert!(status.success());
}