use crate::brain::tools::error::Result;
use crate::brain::tools::read::ReadTool;
use crate::brain::tools::registry::ToolRegistry;
use crate::brain::tools::scope;
use crate::brain::tools::{Tool, ToolCapability, ToolExecutionContext, ToolResult};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::collections::HashSet;
use uuid::Uuid;
struct NamedTool {
name: &'static str,
}
#[async_trait]
impl Tool for NamedTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"stub"
}
fn input_schema(&self) -> Value {
json!({"type": "object", "properties": {}})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![]
}
async fn execute(&self, _input: Value, _ctx: &ToolExecutionContext) -> Result<ToolResult> {
Ok(ToolResult::success("ok".to_string()))
}
}
fn registry_with_the_owner_surface() -> ToolRegistry {
let reg = ToolRegistry::new();
for name in [
"web_search",
"read_file",
"tool_search",
"session_search",
"channel_search",
"load_brain_file",
"memory_search",
"bash",
"write_file",
] {
reg.register(std::sync::Arc::new(NamedTool { name }));
}
reg
}
fn names_of(defs: &[crate::brain::provider::Tool]) -> HashSet<String> {
defs.iter().map(|t| t.name.clone()).collect()
}
fn message(result: &ToolResult) -> String {
if result.output.is_empty() {
result.error.clone().unwrap_or_default()
} else {
result.output.clone()
}
}
#[test]
fn a_guest_schema_set_is_the_allowlist_and_nothing_else() {
let reg = registry_with_the_owner_surface();
let guest = reg.get_tool_definitions_scoped(&HashSet::new(), true);
let names = names_of(&guest);
for allowed in ["web_search", "read_file", "tool_search"] {
assert!(
names.contains(allowed),
"a guest must keep '{allowed}'; got {names:?}"
);
}
for refused in [
"session_search",
"channel_search",
"load_brain_file",
"memory_search",
"bash",
"write_file",
] {
assert!(
!names.contains(refused),
"a guest must never be shown '{refused}'; got {names:?}"
);
}
let guest_unlazy = names_of(®.get_tool_definitions_for_guest());
assert_eq!(
guest_unlazy, names,
"the lazy and unlazy guest sets must be identical"
);
}
#[test]
fn the_owner_surface_is_untouched_by_the_scope() {
let reg = registry_with_the_owner_surface();
let all = names_of(®.get_tool_definitions());
assert_eq!(
all.len(),
9,
"an owner turn keeps the full set; got {all:?}"
);
let lazy = names_of(®.get_tool_definitions_scoped(&HashSet::new(), false));
let lazy_unscoped = names_of(®.get_tool_definitions_filtered(&HashSet::new()));
assert_eq!(
lazy, lazy_unscoped,
"the scope flag must not narrow an owner's lazy set; got {lazy:?}"
);
assert!(
lazy.contains("load_brain_file") && lazy.contains("bash"),
"an owner's core set must keep the personal-context tools; got {lazy:?}"
);
}
#[tokio::test]
async fn a_guest_call_to_a_refused_tool_is_rejected_at_dispatch() {
let reg = registry_with_the_owner_surface();
let ctx = ToolExecutionContext::new(Uuid::new_v4()).with_guest_scope(true);
let out = reg
.execute("session_search", json!({"query": "anything"}), &ctx)
.await
.expect("dispatch must return a refusal, not an error");
assert!(!out.success, "a guest session_search must be refused");
assert!(
message(&out).contains("not available to a guest turn"),
"the refusal must say why: {}",
message(&out)
);
assert!(
message(&out).contains("web_search"),
"the refusal must name what IS reachable, so the model stops retrying: {}",
message(&out)
);
let out = reg
.execute("load_brain_file", json!({"name": "MEMORY.md"}), &ctx)
.await
.expect("dispatch must return a refusal, not an error");
assert!(!out.success, "a guest load_brain_file must be refused");
let yolo = ToolExecutionContext::new(Uuid::new_v4())
.with_guest_scope(true)
.with_auto_approve(true);
for name in ["bash", "write_file"] {
let out = reg
.execute(name, json!({"command": "echo hi", "path": "/tmp/x"}), &yolo)
.await
.expect("dispatch must return a refusal, not an error");
assert!(!out.success, "a guest {name} must be refused even on yolo");
}
}
#[tokio::test]
async fn a_guest_read_outside_the_working_directory_is_refused() {
let ctx = ToolExecutionContext::new(Uuid::new_v4())
.with_guest_scope(true)
.with_working_directory(std::env::temp_dir());
let out = ReadTool
.execute(json!({"path": "/etc/hosts"}), &ctx)
.await
.expect("a refusal is a result, not an error");
assert!(
!out.success,
"a guest read outside the tree must be refused"
);
assert!(
message(&out).contains("outside the working directory"),
"the refusal must name the confinement: {}",
message(&out)
);
let dir = tempfile::tempdir().expect("temp dir");
let inside = dir.path().join("notes.txt");
std::fs::write(&inside, "hello from inside\n").expect("write");
let ctx = ToolExecutionContext::new(Uuid::new_v4())
.with_guest_scope(true)
.with_working_directory(dir.path().to_path_buf());
let out = ReadTool
.execute(json!({"path": inside.to_str().unwrap()}), &ctx)
.await
.expect("read");
assert!(
out.success,
"a permitted read must still work: {}",
message(&out)
);
assert!(message(&out).contains("hello from inside"));
#[cfg(unix)]
{
let link = dir.path().join("escape");
std::os::unix::fs::symlink("/etc/hosts", &link).expect("symlink");
let out = ReadTool
.execute(json!({"path": link.to_str().unwrap()}), &ctx)
.await
.expect("read");
assert!(
!out.success,
"a symlink out of the tree must not be a way around the scope: {}",
message(&out)
);
}
}
#[test]
fn the_confinement_helper_resolves_before_it_compares() {
let root = std::path::Path::new("/tmp/tree");
assert!(scope::path_stays_inside(
root,
std::path::Path::new("/tmp/tree/a/b.rs")
));
assert!(scope::path_stays_inside(
root,
std::path::Path::new("/tmp/tree")
));
assert!(!scope::path_stays_inside(
root,
std::path::Path::new("/tmp/treehouse/a.rs")
));
assert!(!scope::path_stays_inside(
root,
std::path::Path::new("/etc/hosts")
));
assert!(!scope::path_stays_inside(
root,
std::path::Path::new("/tmp/tree/../elsewhere/x")
));
}
#[test]
fn the_allowlist_is_a_short_public_surface() {
assert!(scope::guest_allows("web_search"));
assert!(scope::guest_allows("read_file"));
assert!(!scope::guest_allows("session_search"));
assert!(!scope::guest_allows("channel_search"));
assert!(!scope::guest_allows("load_brain_file"));
assert!(!scope::guest_allows("memory_search"));
assert!(!scope::guest_allows("bash"));
assert!(!scope::guest_allows("write_file"));
assert!(!scope::guest_allows("config_manager"));
assert!(!scope::guest_allows("spawn_agent"));
assert!(!scope::guest_allows("a_tool_invented_next_week"));
}