use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use leviath_mcp::{ToolDiscovery, ToolExecutor};
use leviath_providers::Tool;
use leviath_tools::{BuiltinTools, ToolContext};
use crate::config::{Config, ToolPolicy};
pub struct ToolRegistry {
pub builtins: Arc<BuiltinTools>,
pub mcp: Arc<Mutex<ToolExecutor>>,
pub mcp_tool_defs: Vec<Tool>,
pub builtin_names: HashSet<String>,
}
impl ToolRegistry {
pub async fn build(workdir: PathBuf, config: &Config) -> Self {
let ctx = ToolContext::new(workdir);
let builtins = Arc::new(BuiltinTools::new(ctx));
let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
let mut mcp_executor = ToolExecutor::new();
let mut mcp_tool_defs: Vec<Tool> = Vec::new();
if !config.mcp_servers.is_empty() {
let mut discovery = ToolDiscovery::new();
let oauth = leviath_mcp::OAuthClient::new();
let store_path = leviath_mcp::AuthStore::default_path();
let now = unix_now_secs();
let credentials = credential_store_or_warn(crate::credentials::store_for(
config.security.credential_store,
));
for server_cfg in &config.mcp_servers {
let auth_header = match resolve_bearer(
&oauth,
&server_cfg.name,
store_path.as_deref(),
now,
credentials.as_deref(),
)
.await
{
Ok(header) => header,
Err(e) => {
tracing::warn!(server = %server_cfg.name, error = %e, "MCP auth unavailable - skipping");
continue;
}
};
let auth_was_resolved = auth_header.is_some();
match discovery
.discover_from_config_with_auth(
server_cfg,
auth_header,
&config.security.allow_env_vars,
)
.await
{
Ok((_tool_metas, mut client)) => {
if auth_was_resolved && let Some(path) = store_path.clone() {
client.set_refresher(std::sync::Arc::new(
leviath_mcp::StoredTokenRefresher::new(
server_cfg.name.clone(),
path,
),
));
}
let mut reserved: HashSet<String> = builtin_names.clone();
reserved.extend(mcp_tool_defs.iter().map(|t| t.name.clone()));
let advertised = mcp_executor.add_client_advertised(
server_cfg.name.clone(),
client,
&reserved,
);
for meta in advertised {
mcp_tool_defs.push(Tool {
name: meta.name,
description: meta.description,
parameters: meta.schema,
});
}
tracing::info!(server = %server_cfg.name, "Connected MCP server");
}
Err(e) => {
let span = tracing::warn_span!(
"mcp_server_connect_failed",
server = tracing::field::Empty,
error = tracing::field::Empty
);
let _enter = span.enter();
span.record("server", tracing::field::display(&server_cfg.name));
span.record("error", tracing::field::display(&e));
tracing::warn!("Failed to connect MCP server - skipping");
}
}
}
}
Self {
builtins,
mcp: Arc::new(Mutex::new(mcp_executor)),
mcp_tool_defs,
builtin_names,
}
}
pub fn all_tool_defs(&self) -> Vec<Tool> {
let mut tools = self.builtins.tool_defs();
tools.extend(BuiltinTools::subagent_tool_defs());
tools.extend_from_slice(&self.mcp_tool_defs);
tools
}
pub async fn shutdown(&self) {
let mut mcp = self.mcp.lock().await;
let _ = mcp.shutdown_all().await;
}
}
pub(crate) fn unix_now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn credential_store_or_warn(
resolved: crate::credentials::Resolved,
) -> Option<Box<dyn leviath_core::CredentialStore>> {
match resolved {
Ok(store) => store,
Err(e) => {
tracing::warn!("{e}. MCP servers needing OAuth will appear logged out.");
None
}
}
}
pub(crate) async fn resolve_bearer(
oauth: &leviath_mcp::OAuthClient,
server_name: &str,
store_path: Option<&std::path::Path>,
now: u64,
credentials: Option<&dyn leviath_core::CredentialStore>,
) -> anyhow::Result<Option<(String, String)>> {
match store_path {
Some(path) => {
oauth
.authorization_header_with(server_name, path, now, credentials)
.await
}
None => Ok(None),
}
}
pub fn default_tool_policy(tool_name: &str, is_builtin: bool) -> ToolPolicy {
match leviath_tools::canonical_tool_name(tool_name) {
"read_file" | "read_files" | "list_dir" => ToolPolicy::Allow,
"context_write" | "context_append" | "context_read" | "context_delete" | "context_list"
| "todo_add" | "todo_done" | "todo_note" => ToolPolicy::Allow,
"write_file" | "edit_file" | "shell" => ToolPolicy::Ask,
"spawn_agent" | "check_agent" | "wait_for_agent" | "send_to_agent" | "kill_agent" => {
ToolPolicy::Allow
}
"ask_user_text" | "ask_user_choice" | "ask_user_confirm" | "edit_document" => {
ToolPolicy::Allow
}
_ => {
let _ = is_builtin;
ToolPolicy::Ask
}
}
}
fn restrictiveness(p: ToolPolicy) -> u8 {
match p {
ToolPolicy::Allow => 0,
ToolPolicy::Ask => 1,
ToolPolicy::Deny => 2,
}
}
fn stricter(a: ToolPolicy, b: ToolPolicy) -> ToolPolicy {
if restrictiveness(b) > restrictiveness(a) {
b
} else {
a
}
}
pub fn clamp_by_effect(
tool_name: &str,
arguments: &serde_json::Value,
policy: ToolPolicy,
write_policy: &dyn Fn() -> ToolPolicy,
) -> ToolPolicy {
if leviath_tools::canonical_tool_name(tool_name) != "shell" {
return policy;
}
let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
return policy;
};
if crate::shell_keys::writes_a_file(command) {
return stricter(policy, write_policy());
}
policy
}
pub fn escaping_write_refusal(
tool_name: &str,
arguments: &serde_json::Value,
workdir: &std::path::Path,
) -> Option<String> {
if leviath_tools::canonical_tool_name(tool_name) != "shell" {
return None;
}
let command = arguments.get("command").and_then(|v| v.as_str())?;
let escaping = crate::shell_keys::write_target_paths(command)
.into_iter()
.find(|target| {
let joined = match std::path::Path::new(target).is_absolute() {
true => std::path::PathBuf::from(target),
false => workdir.join(target),
};
!leviath_core::resolves_within(&joined, workdir)
})?;
Some(format!(
"[denied] Shell redirect writes to '{escaping}', which is outside the working directory \
({}). The `write_file` tool refuses the same path; a redirect is not a way around it. \
Write inside the workspace instead.",
workdir.display()
))
}
pub fn declared_write_bytes(tool_name: &str, arguments: &serde_json::Value) -> Option<u64> {
let field = match leviath_tools::canonical_tool_name(tool_name) {
"write_file" => "content",
"edit_file" => "new_str",
_ => return None,
};
arguments
.get(field)
.and_then(|v| v.as_str())
.map(|s| s.len() as u64)
}
pub fn write_budget_refusal(
tool_name: &str,
arguments: &serde_json::Value,
workdir: &std::path::Path,
budget: &crate::daemon::tool_service::WriteBudget,
) -> Option<String> {
let declared = declared_write_bytes(tool_name, arguments);
let writes_something = declared.is_some()
|| (leviath_tools::canonical_tool_name(tool_name) == "shell"
&& arguments
.get("command")
.and_then(|v| v.as_str())
.is_some_and(crate::shell_keys::writes_a_file));
if !writes_something {
return None;
}
budget.check(workdir, declared.unwrap_or(0)).refusal()
}
pub fn measured_write_bytes(
tool_name: &str,
arguments: &serde_json::Value,
workdir: &std::path::Path,
) -> u64 {
if declared_write_bytes(tool_name, arguments).is_some() {
return 0;
}
if leviath_tools::canonical_tool_name(tool_name) != "shell" {
return 0;
}
let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
return 0;
};
crate::shell_keys::write_target_paths(command)
.into_iter()
.map(|target| {
let path = match std::path::Path::new(&target).is_absolute() {
true => std::path::PathBuf::from(&target),
false => workdir.join(&target),
};
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
})
.sum()
}
const BLUEPRINT_LOOSENABLE: &[&str] = &["web_search", "web_fetch"];
fn blueprint_loosenable(tool_name: &str) -> bool {
leviath_tools::tool_name_spellings(tool_name).any(|n| BLUEPRINT_LOOSENABLE.contains(&n))
}
pub fn resolve_policy(
tool_name: &str,
is_builtin: bool,
launch_overrides: &HashMap<String, ToolPolicy>,
stage_permissions: &HashMap<String, String>,
agent_permissions: &HashMap<String, String>,
global_permissions: &HashMap<String, ToolPolicy>,
blueprint_may_loosen: bool,
) -> ToolPolicy {
let ceiling = by_any_spelling(global_permissions, tool_name).copied();
let blueprint = by_any_spelling(stage_permissions, tool_name)
.or_else(|| by_any_spelling(agent_permissions, tool_name))
.map(|s| parse_policy_str(s));
let configured = match (blueprint, ceiling) {
(Some(b), Some(c)) => stricter(b, c),
(Some(b), None) => {
let default = default_tool_policy(tool_name, is_builtin);
match blueprint_may_loosen || blueprint_loosenable(tool_name) {
true => b,
false => stricter(b, default),
}
}
(None, Some(c)) => c,
(None, None) => default_tool_policy(tool_name, is_builtin),
};
if configured == ToolPolicy::Deny {
return ToolPolicy::Deny;
}
by_any_spelling(launch_overrides, tool_name)
.or_else(|| launch_overrides.get("*"))
.copied()
.unwrap_or(configured)
}
pub fn session_approval_keys(tool_name: &str, arguments: &serde_json::Value) -> Vec<String> {
if leviath_tools::canonical_tool_name(tool_name) != "shell" {
return vec![tool_name.to_string()];
}
let Some(command) = arguments.get("command").and_then(|v| v.as_str()) else {
return Vec::new();
};
crate::shell_keys::command_keys(command)
}
fn by_any_spelling<'a, V>(map: &'a HashMap<String, V>, tool_name: &str) -> Option<&'a V> {
leviath_tools::tool_name_spellings(tool_name).find_map(|name| map.get(name))
}
fn parse_policy_str(s: &str) -> ToolPolicy {
match s.to_lowercase().as_str() {
"allow" => ToolPolicy::Allow,
"deny" => ToolPolicy::Deny,
_ => ToolPolicy::Ask,
}
}
#[cfg(test)]
mod mcp_registry_tests {
use super::*;
use crate::test_support::with_tracing;
use leviath_mcp::MCPServerConfig;
const STUB_INIT_AND_LIST: &str = r#"
import sys, json
def respond(id, result):
msg = json.dumps({"jsonrpc": "2.0", "id": id, "result": result})
sys.stdout.write(msg + "\n")
sys.stdout.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
method = req.get("method", "")
id_ = req.get("id")
if method == "initialize":
respond(id_, {"capabilities": {"tools": {"listChanged": True}}, "protocolVersion": "2024-11-05"})
elif method == "notifications/initialized":
pass
elif method == "tools/list":
respond(id_, {"tools": [{"name": "echo", "description": "echo tool", "inputSchema": {}}]})
elif method == "tools/call":
args = req.get("params", {}).get("arguments", {})
if args.get("fail"):
respond(id_, {"content": [{"type": "text", "text": "it broke"}], "isError": True})
else:
respond(id_, {"content": [{"type": "text", "text": "echoed!"}], "isError": False})
else:
respond(id_, {"error": {"code": -32601, "message": "method not found"}})
"#;
fn config_with_mcp_server(command: &str, args: Vec<&str>) -> Config {
Config {
mcp_servers: vec![MCPServerConfig::stdio(
"stub-server",
command,
args.into_iter().map(String::from).collect(),
)],
..Config::default()
}
}
async fn with_temp_home<F, Fut, T>(body: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let dir = tempfile::tempdir().unwrap();
temp_env::async_with_vars(
[("LEVIATH_HOME", Some(dir.path().to_str().unwrap()))],
body(),
)
.await
}
#[tokio::test]
async fn build_connects_mcp_server_and_registers_its_tools() {
with_tracing(|| {});
let registry = with_temp_home(|| async {
let config = config_with_mcp_server("python3", vec!["-c", STUB_INIT_AND_LIST]);
ToolRegistry::build(std::env::temp_dir(), &config).await
})
.await;
assert_eq!(registry.mcp_tool_defs.len(), 1);
assert_eq!(registry.mcp_tool_defs[0].name, "echo");
registry.shutdown().await;
}
#[tokio::test]
async fn build_advertises_two_servers_and_namespaces_a_collision() {
with_tracing(|| {});
let registry = with_temp_home(|| async {
let config = Config {
mcp_servers: vec![
MCPServerConfig::stdio(
"alpha",
"python3",
vec!["-c".to_string(), STUB_INIT_AND_LIST.to_string()],
),
MCPServerConfig::stdio(
"beta",
"python3",
vec!["-c".to_string(), STUB_INIT_AND_LIST.to_string()],
),
],
..Config::default()
};
ToolRegistry::build(std::env::temp_dir(), &config).await
})
.await;
let names: Vec<&str> = registry
.mcp_tool_defs
.iter()
.map(|t| t.name.as_str())
.collect();
assert!(names.contains(&"echo"), "names: {names:?}");
assert!(names.contains(&"beta__echo"), "names: {names:?}");
registry.shutdown().await;
}
async fn mock_http_mcp_server() -> String {
use axum::response::IntoResponse;
use axum::routing::post;
use axum::{Json, Router};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let app = Router::new().route(
"/mcp",
post(|body: String| async move {
let req: serde_json::Value = serde_json::from_str(&body).unwrap();
let id = req.get("id").cloned().unwrap_or(serde_json::json!(1));
let result = match req.get("method").and_then(|m| m.as_str()) {
Some("initialize") => {
serde_json::json!({"capabilities": {}, "protocolVersion": "2024-11-05"})
}
Some("tools/list") => {
serde_json::json!({"tools": [{"name": "remote_tool", "inputSchema": {}}]})
}
_ => serde_json::json!({}),
};
(
[(axum::http::header::CONTENT_TYPE, "application/json")],
Json(serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}))
.into_response()
.into_body(),
)
.into_response()
}),
);
tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
listener, app,
)));
base
}
#[tokio::test]
async fn build_attaches_a_refresher_to_an_authenticated_http_server() {
with_tracing(|| {});
let base = mock_http_mcp_server().await;
let registry = with_temp_home(|| async {
let mut store = leviath_mcp::AuthStore::default();
store.set(
"remote",
leviath_mcp::ServerAuth {
access_token: "live-token".to_string(),
expires_at: u64::MAX,
..Default::default()
},
);
store
.save(&leviath_mcp::AuthStore::default_path().unwrap())
.unwrap();
let config = Config {
mcp_servers: vec![MCPServerConfig::http("remote", format!("{base}/mcp"))],
..Config::default()
};
ToolRegistry::build(std::env::temp_dir(), &config).await
})
.await;
assert_eq!(registry.mcp_tool_defs.len(), 1);
assert_eq!(registry.mcp_tool_defs[0].name, "remote_tool");
registry.shutdown().await;
}
#[tokio::test]
async fn build_skips_mcp_server_that_fails_to_connect() {
with_tracing(|| {});
let registry = with_temp_home(|| async {
let config = config_with_mcp_server("definitely-not-a-real-binary-xyz", vec![]);
ToolRegistry::build(std::env::temp_dir(), &config).await
})
.await;
assert!(registry.mcp_tool_defs.is_empty());
}
#[tokio::test]
async fn build_skips_http_server_whose_token_cannot_be_refreshed() {
with_tracing(|| {});
let registry = with_temp_home(|| async {
let mut store = leviath_mcp::AuthStore::default();
store.set(
"remote",
leviath_mcp::ServerAuth {
token_endpoint: "http://127.0.0.1:1/token".to_string(),
access_token: "expired".to_string(),
refresh_token: Some("good".to_string()),
expires_at: 1,
..Default::default()
},
);
store
.save(&leviath_mcp::AuthStore::default_path().unwrap())
.unwrap();
let config = Config {
mcp_servers: vec![MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp")],
..Config::default()
};
ToolRegistry::build(std::env::temp_dir(), &config).await
})
.await;
assert!(registry.mcp_tool_defs.is_empty());
}
#[test]
fn an_unreachable_credential_store_warns_rather_than_failing_tool_setup() {
assert!(
credential_store_or_warn(Err("no keychain here".to_string())).is_none(),
"an unreachable store yields no credentials"
);
assert!(
credential_store_or_warn(Ok(None)).is_none(),
"and so does the file backend"
);
assert!(
credential_store_or_warn(Ok(Some(Box::new(leviath_core::MemoryStore::new()))))
.is_some()
);
}
#[tokio::test]
async fn resolve_bearer_without_a_store_is_none() {
let oauth = leviath_mcp::OAuthClient::new();
let header = resolve_bearer(&oauth, "srv", None, 0, None).await.unwrap();
assert!(header.is_none());
}
#[tokio::test]
async fn shutdown_with_no_servers_is_a_noop() {
let config = Config::default();
let registry = ToolRegistry::build(std::env::temp_dir(), &config).await;
registry.shutdown().await; }
}
#[cfg(test)]
mod policy_tests {
use super::*;
fn shell_call(command: &str) -> serde_json::Value {
serde_json::json!({ "command": command })
}
fn deny() -> ToolPolicy {
ToolPolicy::Deny
}
fn allow() -> ToolPolicy {
ToolPolicy::Allow
}
#[test]
fn a_redirect_outside_the_workdir_is_refused() {
let dir = tempfile::tempdir().expect("tempdir");
for command in [
"echo pwn > /root/.bashrc",
"cat notes.md > ../escaped.txt",
"echo x >> ../../etc/hosts",
] {
let refusal = escaping_write_refusal("shell", &shell_call(command), dir.path());
assert!(
refusal.is_some(),
"{command:?} writes outside the workdir and must be refused"
);
let refusal = refusal.expect("just asserted");
assert!(
refusal.contains("outside the working directory"),
"{refusal}"
);
}
}
#[test]
fn a_redirect_inside_the_workdir_is_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
for command in [
"echo x > out.txt",
"echo x > sub/dir/out.txt",
"cat a >> ./notes.md",
"ninja > /dev/null 2>&1",
"cat a 2>/dev/null",
"ls",
] {
assert_eq!(
escaping_write_refusal("shell", &shell_call(command), dir.path()),
None,
"{command:?} stays inside and must not be refused"
);
}
}
#[test]
fn an_absolute_path_into_the_workdir_is_allowed() {
let dir = tempfile::tempdir().expect("tempdir");
let inside = dir.path().join("out.txt");
let command = format!("echo x > {}", inside.display());
assert_eq!(
escaping_write_refusal("shell", &shell_call(&command), dir.path()),
None
);
}
#[test]
fn the_refusal_covers_the_alias_and_ignores_other_tools() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(
escaping_write_refusal("bash", &shell_call("echo x > /root/.bashrc"), dir.path(),)
.is_some()
);
assert_eq!(
escaping_write_refusal(
"write_file",
&serde_json::json!({ "path": "/root/.bashrc", "content": "x" }),
dir.path(),
),
None
);
assert_eq!(
escaping_write_refusal("shell", &serde_json::json!({}), dir.path()),
None
);
}
#[test]
fn a_declaring_tool_reports_its_size_and_a_shell_call_does_not() {
assert_eq!(
declared_write_bytes("write_file", &serde_json::json!({"content": "abcd"})),
Some(4)
);
assert_eq!(
declared_write_bytes("edit_file", &serde_json::json!({"new_str": "abc"})),
Some(3)
);
assert_eq!(
declared_write_bytes("shell", &shell_call("echo x > out.txt")),
None
);
assert_eq!(
declared_write_bytes("write_file", &serde_json::json!({})),
None
);
}
#[test]
fn measuring_charges_the_shell_and_not_the_declaring_tools() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("out.txt"), "0123456789").expect("write");
assert_eq!(
measured_write_bytes("shell", &shell_call("echo x > out.txt"), dir.path()),
10
);
assert_eq!(
measured_write_bytes(
"write_file",
&serde_json::json!({"path": "a", "content": "abcd"}),
dir.path()
),
0
);
assert_eq!(
measured_write_bytes("shell", &shell_call("echo x > absent.txt"), dir.path()),
0
);
assert_eq!(
measured_write_bytes("shell", &shell_call("ls -la"), dir.path()),
0
);
let absolute = dir.path().join("out.txt");
let command = format!("echo x > {}", absolute.display());
assert_eq!(
measured_write_bytes("shell", &shell_call(&command), dir.path()),
10
);
assert_eq!(
measured_write_bytes("shell", &serde_json::json!({}), dir.path()),
0
);
assert_eq!(
measured_write_bytes("read_file", &serde_json::json!({"path": "a"}), dir.path()),
0
);
}
#[test]
fn a_denied_write_tool_denies_a_shell_redirect() {
assert_eq!(
clamp_by_effect(
"shell",
&shell_call("echo pwn > /root/.bashrc"),
ToolPolicy::Allow,
&deny,
),
ToolPolicy::Deny
);
assert_eq!(
clamp_by_effect(
"bash",
&shell_call("echo pwn >> ~/.profile"),
ToolPolicy::Allow,
&deny,
),
ToolPolicy::Deny
);
}
#[test]
fn the_clamp_never_loosens_a_shell_call() {
assert_eq!(
clamp_by_effect(
"shell",
&shell_call("echo x > out"),
ToolPolicy::Ask,
&allow,
),
ToolPolicy::Ask
);
assert_eq!(
clamp_by_effect(
"shell",
&shell_call("echo x > out"),
ToolPolicy::Deny,
&allow,
),
ToolPolicy::Deny
);
}
#[test]
fn a_call_that_writes_nothing_is_untouched() {
for command in ["ls -la", "cat a 2>/dev/null", "grep x f", "sort < in"] {
assert_eq!(
clamp_by_effect("shell", &shell_call(command), ToolPolicy::Allow, &deny,),
ToolPolicy::Allow,
"{command:?} writes nothing"
);
}
}
#[test]
fn the_write_policy_is_resolved_only_when_a_call_actually_writes() {
let calls = std::cell::Cell::new(0);
let resolve = || {
calls.set(calls.get() + 1);
ToolPolicy::Deny
};
clamp_by_effect(
"read_file",
&serde_json::json!({"path": "a"}),
ToolPolicy::Allow,
&resolve,
);
clamp_by_effect("shell", &shell_call("ls -la"), ToolPolicy::Allow, &resolve);
clamp_by_effect(
"shell",
&shell_call("cat a 2>/dev/null"),
ToolPolicy::Allow,
&resolve,
);
assert_eq!(
calls.get(),
0,
"nothing here writes, so nothing should resolve"
);
clamp_by_effect(
"shell",
&shell_call("echo x > f"),
ToolPolicy::Allow,
&resolve,
);
assert_eq!(calls.get(), 1, "a writing call resolves it exactly once");
}
#[test]
fn a_non_shell_tool_and_a_malformed_call_are_untouched() {
assert_eq!(
clamp_by_effect(
"read_file",
&serde_json::json!({ "path": "a > b" }),
ToolPolicy::Allow,
&deny,
),
ToolPolicy::Allow
);
assert_eq!(
clamp_by_effect(
"shell",
&serde_json::json!({ "not_a_command": 1 }),
ToolPolicy::Allow,
&deny,
),
ToolPolicy::Allow
);
}
fn blueprint_says(tool: &str, policy: &str, may_loosen: bool) -> ToolPolicy {
let mut agent = HashMap::new();
agent.insert(tool.to_string(), policy.to_string());
resolve_policy(
tool,
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&HashMap::new(),
may_loosen,
)
}
#[test]
fn a_blueprint_cannot_loosen_a_tool_the_user_never_configured() {
for tool in ["shell", "write_file", "edit_file"] {
assert_eq!(
blueprint_says(tool, "allow", false),
ToolPolicy::Ask,
"{tool} must fall back to its built-in default"
);
}
assert_eq!(
blueprint_says("spawn_agent", "allow", false),
ToolPolicy::Allow
);
}
#[test]
fn a_blueprint_may_still_tighten_anything() {
assert_eq!(blueprint_says("shell", "deny", false), ToolPolicy::Deny);
assert_eq!(blueprint_says("read_file", "ask", false), ToolPolicy::Ask);
}
#[test]
fn a_blueprint_may_preapprove_the_read_only_web_tools() {
assert_eq!(
blueprint_says("web_fetch", "allow", false),
ToolPolicy::Allow
);
assert_eq!(
blueprint_says("web_search", "allow", false),
ToolPolicy::Allow
);
}
#[test]
fn an_opted_in_blueprint_may_loosen_anything() {
assert_eq!(blueprint_says("shell", "allow", true), ToolPolicy::Allow);
}
#[test]
fn a_configured_ceiling_still_bounds_a_blueprint() {
let mut agent = HashMap::new();
agent.insert("shell".to_string(), "allow".to_string());
let mut global = HashMap::new();
global.insert("shell".to_string(), ToolPolicy::Allow);
assert_eq!(
resolve_policy(
"shell",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&global,
false,
),
ToolPolicy::Allow,
"naming the tool in the user's own config is the per-agent grant"
);
global.insert("shell".to_string(), ToolPolicy::Deny);
assert_eq!(
resolve_policy(
"shell",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&global,
true,
),
ToolPolicy::Deny,
"a configured deny is terminal even for an opted-in blueprint"
);
}
#[test]
fn the_bundled_agents_resolve_unchanged() {
for agent in crate::bundled::BUNDLED_AGENTS {
let (_, manifest) = agent
.files
.iter()
.find(|(rel, _)| rel.ends_with("agent.leviath"))
.expect("every bundled agent ships a manifest");
let bp = leviath_core::manifest::parse_manifest(manifest)
.expect("every bundled agent's manifest parses");
let perms = bp.agent_tool_permissions();
for (tool, declared) in &perms {
let clamped = resolve_policy(
tool,
true,
&HashMap::new(),
&HashMap::new(),
&perms,
&HashMap::new(),
false,
);
let unclamped = resolve_policy(
tool,
true,
&HashMap::new(),
&HashMap::new(),
&perms,
&HashMap::new(),
true,
);
assert_eq!(
clamped, unclamped,
"{}'s {tool} = {declared:?} changed meaning under the allowlist",
agent.name
);
}
}
}
#[test]
fn test_default_policy_read_file() {
assert_eq!(default_tool_policy("read_file", true), ToolPolicy::Allow);
assert_eq!(default_tool_policy("list_dir", true), ToolPolicy::Allow);
}
#[test]
fn test_default_policy_write_tools() {
assert_eq!(default_tool_policy("write_file", true), ToolPolicy::Ask);
assert_eq!(default_tool_policy("edit_file", true), ToolPolicy::Ask);
assert_eq!(default_tool_policy("bash", true), ToolPolicy::Ask);
}
#[test]
fn test_default_policy_ask_user_tools_allow_by_default() {
assert_eq!(
default_tool_policy("ask_user_text", true),
ToolPolicy::Allow
);
assert_eq!(
default_tool_policy("ask_user_choice", true),
ToolPolicy::Allow
);
assert_eq!(
default_tool_policy("ask_user_confirm", true),
ToolPolicy::Allow
);
assert_eq!(
default_tool_policy("edit_document", true),
ToolPolicy::Allow
);
}
#[test]
fn test_resolve_policy_launch_override_wins() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_yolo_wins() {
let mut launch = HashMap::new();
launch.insert("*".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_stage_may_tighten_global() {
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "deny".to_string());
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&stage,
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_stage_cannot_loosen_global() {
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "allow".to_string());
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Deny);
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&stage,
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_blueprint_free_when_user_silent() {
let mut agent = HashMap::new();
agent.insert("web_fetch".to_string(), "allow".to_string());
let policy = resolve_policy(
"web_fetch",
false,
&HashMap::new(),
&HashMap::new(),
&agent,
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_yolo_does_not_override_configured_deny() {
let mut launch = HashMap::new();
launch.insert("*".to_string(), ToolPolicy::Allow);
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Deny);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_named_allow_does_not_override_configured_deny() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Deny);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_yolo_does_not_override_blueprint_deny() {
let mut launch = HashMap::new();
launch.insert("*".to_string(), ToolPolicy::Allow);
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "deny".to_string());
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&agent,
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_yolo_still_collapses_ask_to_allow() {
let mut launch = HashMap::new();
launch.insert("*".to_string(), ToolPolicy::Allow);
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Ask);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_falls_through_to_default() {
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_default_policy_unknown_tools() {
assert_eq!(default_tool_policy("unknown_tool", false), ToolPolicy::Ask);
assert_eq!(default_tool_policy("mcp_tool", false), ToolPolicy::Ask);
assert_eq!(default_tool_policy("custom_thing", true), ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_agent_cannot_loosen_global() {
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "allow".to_string());
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Deny);
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_global_ask_bounds_blueprint_allow() {
let mut agent = HashMap::new();
agent.insert("write_file".to_string(), "allow".to_string());
let mut global = HashMap::new();
global.insert("write_file".to_string(), ToolPolicy::Ask);
let policy = resolve_policy(
"write_file",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&global,
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_launch_override_specific_beats_wildcard() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Deny);
launch.insert("*".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"bash",
true,
&launch,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_global_overrides_default() {
let mut global = HashMap::new();
global.insert("read_file".to_string(), ToolPolicy::Deny);
let policy = resolve_policy(
"read_file",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_stage_deny() {
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "deny".to_string());
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&stage,
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_stage_ask() {
let mut stage = HashMap::new();
stage.insert("read_file".to_string(), "ask".to_string());
let policy = resolve_policy(
"read_file",
true,
&HashMap::new(),
&stage,
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_unknown_stage_string_defaults_to_ask() {
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "unknown_policy".to_string());
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&stage,
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_parse_policy_str_values() {
assert_eq!(parse_policy_str("allow"), ToolPolicy::Allow);
assert_eq!(parse_policy_str("Allow"), ToolPolicy::Allow);
assert_eq!(parse_policy_str("ALLOW"), ToolPolicy::Allow);
assert_eq!(parse_policy_str("deny"), ToolPolicy::Deny);
assert_eq!(parse_policy_str("Deny"), ToolPolicy::Deny);
assert_eq!(parse_policy_str("ask"), ToolPolicy::Ask);
assert_eq!(parse_policy_str("Ask"), ToolPolicy::Ask);
assert_eq!(parse_policy_str("anything_else"), ToolPolicy::Ask);
assert_eq!(parse_policy_str(""), ToolPolicy::Ask);
}
#[tokio::test]
async fn test_tool_registry_build_no_mcp() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
assert!(!registry.builtin_names.is_empty());
assert!(registry.mcp_tool_defs.is_empty());
}
#[tokio::test]
async fn test_tool_registry_all_tool_defs() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
let all_defs = registry.all_tool_defs();
assert!(!all_defs.is_empty());
let names: Vec<&str> = all_defs.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"read_file"));
}
#[tokio::test]
async fn test_tool_registry_builtin_names_consistent() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
let names_from_builtins: HashSet<String> = registry.builtins.names().into_iter().collect();
assert_eq!(registry.builtin_names, names_from_builtins);
}
fn shell_args(command: &str) -> serde_json::Value {
serde_json::json!({ "command": command })
}
#[test]
fn the_bash_alias_is_scoped_like_shell() {
assert_eq!(
session_approval_keys("bash", &shell_args("ls -la")),
["shell:ls"]
);
}
#[test]
fn other_tools_are_keyed_by_name() {
assert_eq!(
session_approval_keys("read_file", &serde_json::json!({ "path": "a" })),
["read_file"]
);
}
#[test]
fn a_shell_call_without_a_command_is_not_grantable() {
assert!(session_approval_keys("shell", &serde_json::json!({})).is_empty());
}
#[test]
fn test_resolve_policy_launch_overrides_stage_ask() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "ask".to_string());
let policy = resolve_policy(
"bash",
true,
&launch,
&stage,
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_launch_cannot_override_stage_deny() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "deny".to_string());
let policy = resolve_policy(
"bash",
true,
&launch,
&stage,
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_stage_overrides_agent() {
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "deny".to_string());
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "allow".to_string());
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&stage,
&agent,
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_agent_overrides_global() {
let mut agent = HashMap::new();
agent.insert("write_file".to_string(), "deny".to_string());
let mut global = HashMap::new();
global.insert("write_file".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"write_file",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&global,
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_wildcard_launch_with_missing_specific() {
let mut launch = HashMap::new();
launch.insert("*".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"unknown_tool",
false,
&launch,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_mcp_tool_defaults_to_ask() {
let policy = resolve_policy(
"mcp_custom_tool",
false,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_read_file_default_is_allow() {
let policy = resolve_policy(
"read_file",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_list_dir_default_is_allow() {
let policy = resolve_policy(
"list_dir",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[test]
fn test_resolve_policy_write_file_default_is_ask() {
let policy = resolve_policy(
"write_file",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_edit_file_default_is_ask() {
let policy = resolve_policy(
"edit_file",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[tokio::test]
async fn test_tool_registry_shutdown_no_panic() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
registry.shutdown().await;
}
#[tokio::test]
async fn test_tool_registry_all_defs_includes_subagent() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
let all_defs = registry.all_tool_defs();
let names: Vec<&str> = all_defs.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"spawn_agent"));
}
#[test]
fn test_default_policy_search_is_ask() {
assert_eq!(default_tool_policy("search", true), ToolPolicy::Ask);
}
#[test]
fn test_default_policy_glob_is_ask() {
assert_eq!(default_tool_policy("glob", true), ToolPolicy::Ask);
}
#[test]
fn test_default_policy_http_request_is_ask() {
assert_eq!(default_tool_policy("http_request", true), ToolPolicy::Ask);
}
#[test]
fn test_default_policy_read_file_not_builtin_still_allow() {
assert_eq!(default_tool_policy("read_file", false), ToolPolicy::Allow);
}
#[test]
fn test_default_policy_list_dir_not_builtin_still_allow() {
assert_eq!(default_tool_policy("list_dir", false), ToolPolicy::Allow);
}
#[test]
fn a_permission_written_as_an_alias_reaches_the_tool() {
let policy = |layer: &str, spelling: &str, called: &str| {
let mut launch = HashMap::new();
let mut stage = HashMap::new();
let mut agent = HashMap::new();
let mut global = HashMap::new();
match layer {
"launch" => {
launch.insert(spelling.to_string(), ToolPolicy::Allow);
}
"stage" => {
stage.insert(spelling.to_string(), "deny".to_string());
}
"agent" => {
agent.insert(spelling.to_string(), "deny".to_string());
}
_ => {
global.insert(spelling.to_string(), ToolPolicy::Deny);
}
}
resolve_policy(called, true, &launch, &stage, &agent, &global, false)
};
assert_eq!(policy("launch", "bash", "shell"), ToolPolicy::Allow);
assert_eq!(policy("stage", "bash", "shell"), ToolPolicy::Deny);
assert_eq!(policy("agent", "bash", "shell"), ToolPolicy::Deny);
assert_eq!(policy("global", "bash", "shell"), ToolPolicy::Deny);
assert_eq!(policy("launch", "shell", "bash"), ToolPolicy::Allow);
assert_eq!(policy("global", "shell", "bash"), ToolPolicy::Deny);
assert_eq!(policy("global", "read_file", "write_file"), ToolPolicy::Ask);
}
#[test]
fn the_default_shell_policy_covers_both_spellings() {
assert_eq!(default_tool_policy("shell", true), ToolPolicy::Ask);
assert_eq!(default_tool_policy("bash", true), ToolPolicy::Ask);
}
#[test]
fn the_context_tools_do_not_prompt() {
for tool in [
"context_write",
"context_append",
"context_read",
"context_delete",
"context_list",
"read_files",
] {
assert_eq!(
default_tool_policy(tool, true),
ToolPolicy::Allow,
"{tool} must not raise a prompt by default"
);
}
}
#[test]
fn test_resolve_policy_agent_deny() {
let mut agent = HashMap::new();
agent.insert("read_file".to_string(), "deny".to_string());
let policy = resolve_policy(
"read_file",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_agent_unknown_string_defaults_to_ask() {
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "foobar".to_string());
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&HashMap::new(),
&agent,
&HashMap::new(),
false,
);
assert_eq!(policy, ToolPolicy::Ask);
}
#[test]
fn test_resolve_policy_global_allow_overrides_default_ask() {
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Allow);
let policy = resolve_policy(
"bash",
true,
&HashMap::new(),
&HashMap::new(),
&HashMap::new(),
&global,
false,
);
assert_eq!(policy, ToolPolicy::Allow);
}
#[tokio::test]
async fn test_tool_registry_all_defs_includes_all_subagent_tools() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
let all_defs = registry.all_tool_defs();
let names: Vec<&str> = all_defs.iter().map(|t| t.name.as_str()).collect();
for expected in &[
"spawn_agent",
"check_agent",
"wait_for_agent",
"send_to_agent",
"kill_agent",
] {
assert!(names.contains(expected));
}
}
#[tokio::test]
async fn test_tool_registry_builtin_names_has_expected_tools() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
for name in &["read_file", "list_dir"] {
assert!(registry.builtin_names.contains(*name));
}
assert!(!registry.builtin_names.contains("spawn_agent"));
}
#[tokio::test]
async fn test_tool_registry_all_defs_no_mcp_when_none_configured() {
let config = Config::default();
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
assert!(registry.mcp_tool_defs.is_empty());
let all_defs = registry.all_tool_defs();
let builtin_count = registry.builtins.tool_defs().len();
let subagent_count = leviath_tools::BuiltinTools::subagent_tool_defs().len();
assert_eq!(all_defs.len(), builtin_count + subagent_count);
}
#[test]
fn test_resolve_policy_full_chain_deny_is_terminal() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "deny".to_string());
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "deny".to_string());
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Deny);
let policy = resolve_policy("bash", true, &launch, &stage, &agent, &global, false);
assert_eq!(policy, ToolPolicy::Deny);
}
#[test]
fn test_resolve_policy_full_chain_launch_relaxes_ask() {
let mut launch = HashMap::new();
launch.insert("bash".to_string(), ToolPolicy::Allow);
let mut stage = HashMap::new();
stage.insert("bash".to_string(), "ask".to_string());
let mut agent = HashMap::new();
agent.insert("bash".to_string(), "ask".to_string());
let mut global = HashMap::new();
global.insert("bash".to_string(), ToolPolicy::Ask);
let policy = resolve_policy("bash", true, &launch, &stage, &agent, &global, false);
assert_eq!(policy, ToolPolicy::Allow);
}
#[tokio::test]
async fn test_tool_registry_build_with_failing_mcp_server() {
use leviath_mcp::MCPServerConfig;
let bad_server = MCPServerConfig::stdio(
"bad-server",
"/nonexistent/binary/that/does/not/exist",
vec![],
);
let config = Config {
mcp_servers: vec![bad_server],
..Config::default()
};
let workdir = std::env::current_dir().unwrap();
let registry = ToolRegistry::build(workdir, &config).await;
assert!(registry.mcp_tool_defs.is_empty());
assert!(!registry.builtin_names.is_empty());
}
const ALLOWED_WITHOUT_ASKING: &[&str] = &[
"read_file",
"read_files",
"list_dir",
"context_write",
"context_append",
"context_read",
"context_delete",
"context_list",
"todo_add",
"todo_done",
"todo_note",
"spawn_agent",
"check_agent",
"wait_for_agent",
"send_to_agent",
"kill_agent",
"ask_user_text",
"ask_user_choice",
"ask_user_confirm",
"edit_document",
];
fn advertised_tool_names() -> Vec<String> {
let dir = tempfile::tempdir().expect("tempdir");
let builtins = leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(
dir.path().to_path_buf(),
));
let mut names: Vec<String> = builtins
.tool_defs()
.into_iter()
.map(|t| t.name)
.chain(
leviath_tools::BuiltinTools::subagent_tool_defs()
.into_iter()
.map(|t| t.name),
)
.collect();
names.sort();
names.dedup();
names
}
#[test]
fn the_tool_discovery_finds_the_real_toolset() {
let names = advertised_tool_names();
let count = names.len();
assert!(count >= 15, "only {count} tools discovered: {names:?}");
for expected in ["read_file", "write_file", "shell"] {
assert!(names.contains(&expected.to_string()), "{expected} missing");
}
}
#[test]
fn only_reviewed_tools_run_without_asking() {
for name in advertised_tool_names() {
let policy = default_tool_policy(&name, true);
let reviewed = ALLOWED_WITHOUT_ASKING.contains(&name.as_str());
match reviewed {
true => assert_eq!(
policy,
ToolPolicy::Allow,
"{name} is on the reviewed unprompted list but does not resolve to Allow"
),
false => assert_ne!(
policy,
ToolPolicy::Allow,
"{name} runs with no prompt and is not on the reviewed list"
),
}
}
}
#[test]
fn an_unknown_tool_asks_rather_than_running() {
for name in [
"definitely_not_a_real_tool",
"mcp__someserver__delete_everything",
"",
] {
assert_eq!(default_tool_policy(name, false), ToolPolicy::Ask, "{name}");
assert_eq!(default_tool_policy(name, true), ToolPolicy::Ask, "{name}");
}
}
#[test]
fn the_effectful_tools_ask_under_every_spelling() {
for name in ["write_file", "edit_file", "shell"] {
for spelling in leviath_tools::tool_name_spellings(name) {
assert_eq!(
default_tool_policy(spelling, true),
ToolPolicy::Ask,
"{spelling:?} (a spelling of {name}) does not ask"
);
}
}
}
}