roboticus-agent 0.10.0

Agent core with ReAct loop, policy engine, injection defense, memory system, and skill loader
Documentation
//! Verify all filesystem tools respect `tool_allowed_paths` consistently.

use super::*;
use std::path::PathBuf;

fn ctx_with_allowed(root: PathBuf, allowed: Vec<PathBuf>) -> ToolContext {
    ToolContext {
        session_id: "test-sandbox".into(),
        agent_id: "test-agent".into(),
        agent_name: "test-agent".into(),
        authority: InputAuthority::Creator,
        workspace_root: root,
        tool_allowed_paths: allowed,
        channel: None,
        db: None,
        sandbox: ToolSandboxSnapshot::default(),
    }
}

fn setup_allowed_dir() -> (tempfile::TempDir, PathBuf, tempfile::TempDir, PathBuf) {
    let ws_dir = tempfile::tempdir().unwrap();
    let ws_root = std::fs::canonicalize(ws_dir.path()).unwrap();
    std::fs::write(ws_root.join("local.txt"), "workspace file").unwrap();

    let ext_dir = tempfile::tempdir().unwrap();
    let ext_root = std::fs::canonicalize(ext_dir.path()).unwrap();
    std::fs::write(ext_root.join("external.txt"), "external file").unwrap();
    std::fs::create_dir_all(ext_root.join("sub")).unwrap();
    std::fs::write(ext_root.join("sub/nested.txt"), "nested").unwrap();

    (ws_dir, ws_root, ext_dir, ext_root)
}

#[tokio::test]
async fn read_file_respects_allowed_paths() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = ReadFileTool;
    let result = tool
        .execute(
            serde_json::json!({"path": ext_root.join("external.txt").to_str().unwrap()}),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
    assert!(result.unwrap().output.contains("external file"));
}

#[tokio::test]
async fn read_file_blocks_unallowed_absolute() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    // Do NOT add ext_root to allowed
    let ctx = ctx_with_allowed(ws_root, vec![]);
    let tool = ReadFileTool;
    let result = tool
        .execute(
            serde_json::json!({"path": ext_root.join("external.txt").to_str().unwrap()}),
            &ctx,
        )
        .await;
    assert!(result.is_err());
}

#[tokio::test]
async fn write_file_respects_allowed_paths() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = WriteFileTool;
    let target = ext_root.join("new_file.txt");
    let result = tool
        .execute(
            serde_json::json!({
                "path": target.to_str().unwrap(),
                "content": "written via tool"
            }),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
    assert_eq!(
        std::fs::read_to_string(&target).unwrap(),
        "written via tool"
    );
}

#[tokio::test]
async fn write_file_blocks_unallowed_path() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![]); // no allowed
    let tool = WriteFileTool;
    let result = tool
        .execute(
            serde_json::json!({
                "path": ext_root.join("sneaky.txt").to_str().unwrap(),
                "content": "nope"
            }),
            &ctx,
        )
        .await;
    assert!(result.is_err());
}

#[tokio::test]
async fn list_dir_respects_allowed_paths() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = ListDirectoryTool;
    let result = tool
        .execute(
            serde_json::json!({"path": ext_root.to_str().unwrap()}),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
    assert!(result.unwrap().output.contains("external.txt"));
}

#[tokio::test]
async fn list_dir_blocks_unallowed_path() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![]); // no allowed
    let tool = ListDirectoryTool;
    let result = tool
        .execute(
            serde_json::json!({"path": ext_root.to_str().unwrap()}),
            &ctx,
        )
        .await;
    assert!(result.is_err());
}

#[tokio::test]
async fn search_files_respects_allowed_paths() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = SearchFilesTool;
    let result = tool
        .execute(
            serde_json::json!({
                "path": ext_root.to_str().unwrap(),
                "query": "external"
            }),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
}

#[tokio::test]
async fn grep_tool_respects_allowed_paths() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = GlobFilesTool;
    let result = tool
        .execute(
            serde_json::json!({
                "path": ext_root.to_str().unwrap(),
                "pattern": "*.txt"
            }),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
}

// ── BashTool cwd confinement tests ──────────────────────────

#[tokio::test]
async fn bash_cwd_in_allowed_path_ok() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![ext_root.clone()]);
    let tool = BashTool;
    let result = tool
        .execute(
            serde_json::json!({
                "command": "ls",
                "cwd": ext_root.to_str().unwrap()
            }),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
    assert!(result.unwrap().output.contains("external.txt"));
}

#[tokio::test]
async fn bash_cwd_outside_allowed_blocked() {
    let (_ws, ws_root, _ext, ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![]);
    let tool = BashTool;
    let result = tool
        .execute(
            serde_json::json!({
                "command": "ls",
                "cwd": ext_root.to_str().unwrap()
            }),
            &ctx,
        )
        .await;
    assert!(result.is_err());
}

#[tokio::test]
async fn bash_cwd_workspace_relative_ok() {
    let (_ws, ws_root, _ext, _ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![]);
    let tool = BashTool;
    let result = tool
        .execute(
            serde_json::json!({
                "command": "echo ok",
                "cwd": "."
            }),
            &ctx,
        )
        .await;
    assert!(result.is_ok(), "got: {:?}", result);
    assert!(result.unwrap().output.contains("ok"));
}

#[tokio::test]
async fn bash_cwd_traversal_blocked() {
    let (_ws, ws_root, _ext, _ext_root) = setup_allowed_dir();
    let ctx = ctx_with_allowed(ws_root, vec![]);
    let tool = BashTool;
    let result = tool
        .execute(
            serde_json::json!({
                "command": "echo pwned",
                "cwd": "../../tmp"
            }),
            &ctx,
        )
        .await;
    assert!(result.is_err());
}