Files
cassady/tests/tool_tests.rs
T

324 lines
9.9 KiB
Rust

use cassady::access::AccessMode;
use cassady::tools::{self, ToolContext};
use serde_json::json;
use tempfile::tempdir;
fn ctx(root: &std::path::Path, mode: AccessMode) -> ToolContext {
ToolContext {
mode,
cwd: root.to_path_buf(),
read_roots: vec![root.to_path_buf()],
blocked_write_roots: Vec::new(),
model_result_limit: 100_000,
runtime_tx: None,
}
}
fn ctx_with_docs(root: &std::path::Path, docs: &std::path::Path, mode: AccessMode) -> ToolContext {
ToolContext {
mode,
cwd: root.to_path_buf(),
read_roots: vec![root.to_path_buf(), docs.to_path_buf()],
blocked_write_roots: vec![docs.to_path_buf()],
model_result_limit: 100_000,
runtime_tx: None,
}
}
#[tokio::test]
async fn read_only_rejects_write_and_path_escape() {
let dir = tempdir().unwrap();
let outside = tempdir().unwrap();
std::fs::write(outside.path().join("x.txt"), "secret").unwrap();
let out = tools::execute(
"write",
json!({"path":"x.txt", "content":"hi"}),
&ctx(dir.path(), AccessMode::ReadOnly),
)
.await;
assert!(!out.ok);
let out = tools::execute(
"read",
json!({"files":[{"path": outside.path().join("x.txt").display().to_string()}]}),
&ctx(dir.path(), AccessMode::ReadOnly),
)
.await;
assert!(!out.ok);
assert!(out.content.contains("escapes read-only root"));
}
#[tokio::test]
async fn read_and_grep_work() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "one\ntwo needle\nthree\n").unwrap();
let context = ctx(dir.path(), AccessMode::ReadOnly);
let read = tools::execute(
"read",
json!({"files":[{"path":"a.txt", "lines":"2-3"}]}),
&context,
)
.await;
assert!(read.ok);
assert!(read.content.contains("two needle"));
assert!(!read.content.contains("one"));
let grep = tools::execute(
"grep",
json!({"query":"needle", "paths":["."], "case_sensitive": true}),
&context,
)
.await;
assert!(grep.ok);
assert!(grep.content.contains("a.txt:2"));
}
#[tokio::test]
async fn read_only_can_read_and_search_docs_root() {
let dir = tempdir().unwrap();
let docs = tempdir().unwrap();
let outside = tempdir().unwrap();
std::fs::write(docs.path().join("guide.md"), "# Guide\nneedle in docs\n").unwrap();
std::fs::write(outside.path().join("secret.txt"), "secret").unwrap();
let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::ReadOnly);
let read = tools::execute(
"read",
json!({"files":[{"path": docs.path().join("guide.md").display().to_string()}]}),
&context,
)
.await;
assert!(read.ok);
assert!(read.content.contains("needle in docs"));
let grep = tools::execute(
"grep",
json!({"query":"needle", "paths":[docs.path().display().to_string()]}),
&context,
)
.await;
assert!(grep.ok);
assert!(grep.content.contains("guide.md:2"));
let outside_read = tools::execute(
"read",
json!({"files":[{"path": outside.path().join("secret.txt").display().to_string()}]}),
&context,
)
.await;
assert!(!outside_read.ok);
assert!(outside_read.content.contains("escapes read-only root"));
}
#[tokio::test]
async fn workspace_edit_allows_workspace_edits_but_blocks_shell_without_approval() {
let dir = tempdir().unwrap();
let outside = tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "alpha\nbeta\n").unwrap();
let context = ctx(dir.path(), AccessMode::WorkspaceEdit);
let edit = tools::execute(
"edit",
json!({"path":"a.txt", "edits":[{"old_text":"beta", "new_text":"BETA"}]}),
&context,
)
.await;
assert!(edit.ok, "{}", edit.content);
assert_eq!(
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"alpha\nBETA\n"
);
let outside_write = tools::execute(
"write",
json!({"path": outside.path().join("x.txt").display().to_string(), "content":"nope"}),
&context,
)
.await;
assert!(!outside_write.ok);
assert!(outside_write
.content
.contains("escapes workspace-edit root"));
let shell = tools::execute("shell", json!({"command":"touch marker"}), &context).await;
assert!(!shell.ok);
assert!(shell.content.contains("approval required"));
assert!(!dir.path().join("marker").exists());
}
#[tokio::test]
async fn workspace_edit_blocks_symlink_escape_write() {
let dir = tempdir().unwrap();
let outside = tempdir().unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(outside.path(), dir.path().join("outside_link")).unwrap();
let context = ctx(dir.path(), AccessMode::WorkspaceEdit);
let write = tools::execute(
"write",
json!({"path":"outside_link/new.txt", "content":"nope"}),
&context,
)
.await;
assert!(!write.ok);
assert!(write.content.contains("escapes workspace-edit root"));
assert!(!outside.path().join("new.txt").exists());
}
}
#[tokio::test]
async fn edit_is_exact_and_atomic_on_validation_failure() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
let context = ctx(dir.path(), AccessMode::FullAccess);
let ok = tools::execute(
"edit",
json!({"path":"a.txt", "edits":[{"old_text":"beta", "new_text":"BETA"}]}),
&context,
)
.await;
assert!(ok.ok);
assert!(ok.content.contains("--- "));
assert!(ok.content.contains("-beta"));
assert!(ok.content.contains("+BETA"));
assert_eq!(
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"alpha\nBETA\ngamma\n"
);
let bad = tools::execute(
"edit",
json!({"path":"a.txt", "edits":[{"old_text":"missing", "new_text":"NOPE"}]}),
&context,
)
.await;
assert!(!bad.ok);
assert_eq!(
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
"alpha\nBETA\ngamma\n"
);
}
#[tokio::test]
async fn full_access_blocks_write_and_edit_under_docs_root() {
let dir = tempdir().unwrap();
let docs = tempdir().unwrap();
std::fs::write(docs.path().join("guide.md"), "original docs\n").unwrap();
let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess);
let write = tools::execute(
"write",
json!({"path": docs.path().join("new.md").display().to_string(), "content":"nope"}),
&context,
)
.await;
assert!(!write.ok);
assert!(write.content.contains("writes are blocked"));
assert!(!docs.path().join("new.md").exists());
let edit = tools::execute(
"edit",
json!({"path": docs.path().join("guide.md").display().to_string(), "edits":[{"old_text":"original", "new_text":"changed"}]}),
&context,
)
.await;
assert!(!edit.ok);
assert!(edit.content.contains("writes are blocked"));
assert_eq!(
std::fs::read_to_string(docs.path().join("guide.md")).unwrap(),
"original docs\n"
);
}
#[tokio::test]
async fn shell_streams_output_chunks() {
let dir = tempdir().unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut context = ctx(dir.path(), AccessMode::FullAccess);
context.runtime_tx = Some(tx);
let out = tools::execute(
"shell",
json!({"command":"printf hello; printf err >&2"}),
&context,
)
.await;
assert!(out.ok);
assert!(out.content.contains("stdout:\nhello"));
assert!(out.content.contains("stderr:\nerr"));
let mut streamed = String::new();
while let Ok(event) = rx.try_recv() {
let cassady::tools::ToolRuntimeEvent::OutputChunk { stream, content } = event;
streamed.push_str(&format!("{stream}:{content}"));
}
assert!(streamed.contains("stdout:hello"));
assert!(streamed.contains("stderr:err"));
}
#[tokio::test]
async fn aborting_shell_tool_stops_command_before_completion() {
let dir = tempdir().unwrap();
let marker = dir.path().join("marker");
let context = ctx(dir.path(), AccessMode::FullAccess);
let command = "sleep 1; touch marker";
let handle = tokio::spawn(async move {
tools::execute(
"shell",
json!({"command": command, "timeout": 10}),
&context,
)
.await
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
handle.abort();
assert!(handle.await.unwrap_err().is_cancelled());
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
assert!(!marker.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn full_access_blocks_writes_through_symlinked_docs_dir() {
let dir = tempdir().unwrap();
let docs = tempdir().unwrap();
std::os::unix::fs::symlink(docs.path(), dir.path().join("docs_link")).unwrap();
let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess);
let write = tools::execute(
"write",
json!({"path":"docs_link/new.md", "content":"nope"}),
&context,
)
.await;
assert!(!write.ok);
assert!(write.content.contains("writes are blocked"));
assert!(!docs.path().join("new.md").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn full_access_blocks_lexical_writes_under_docs_even_when_symlink_points_outside() {
let dir = tempdir().unwrap();
let docs = tempdir().unwrap();
let outside = tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), docs.path().join("outside_link")).unwrap();
let context = ctx_with_docs(dir.path(), docs.path(), AccessMode::FullAccess);
let write = tools::execute(
"write",
json!({"path": docs.path().join("outside_link/new.md").display().to_string(), "content":"nope"}),
&context,
)
.await;
assert!(!write.ok);
assert!(write.content.contains("writes are blocked"));
assert!(!outside.path().join("new.md").exists());
}