mod bash;
mod edit;
mod fetch_full_output;
mod file_tracker;
mod glob;
mod grep;
pub mod handoff;
mod image_generate;
mod list;
mod patch;
mod question;
mod read;
mod recall_context;
mod skill;
mod todo;
mod tts;
#[cfg(feature = "ghost-domain-config")]
pub(crate) mod twitter_post;
mod webfetch;
mod websearch;
mod write;
use std::path::PathBuf;
use std::sync::Arc;
use crate::tool::Tool;
fn is_protected(path: &std::path::Path, protected: &[PathBuf]) -> bool {
let normalized = crate::workspace::normalize_path(path);
for pp in protected {
if normalized.starts_with(pp) || normalized == *pp {
return true;
}
if let Some(pattern) = pp.to_str()
&& let Some(pat_ext) = pattern.strip_prefix("*.")
&& let Some(ext) = normalized.extension().and_then(|e| e.to_str())
&& ext.eq_ignore_ascii_case(pat_ext)
{
return true;
}
}
false
}
fn is_protected_resolved(path: &std::path::Path, protected: &[PathBuf]) -> bool {
if is_protected(path, protected) {
return true;
}
if let Ok(canonical) = path.canonicalize()
&& canonical != path
&& is_protected(&canonical, protected)
{
return true;
}
false
}
pub(crate) async fn write_no_follow(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let path_owned = path.to_path_buf();
let bytes = bytes.to_vec();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.custom_flags(libc::O_NOFOLLOW)
.open(&path_owned)?;
file.write_all(&bytes)?;
file.sync_all()?;
Ok(())
})
.await
.map_err(|e| std::io::Error::other(format!("spawn_blocking failed: {e}")))?
}
#[cfg(not(unix))]
{
tokio::fs::write(path, bytes).await
}
}
#[cfg(unix)]
pub(crate) async fn write_beneath_root(
root: &std::path::Path,
target: &std::path::Path,
bytes: &[u8],
) -> std::io::Result<()> {
use std::ffi::CString;
use std::io::{Error, ErrorKind, Write};
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt;
use std::path::Component;
let rel = target
.strip_prefix(root)
.map_err(|_| Error::new(ErrorKind::InvalidInput, "target is not beneath the root"))?;
let mut comps: Vec<CString> = Vec::new();
for c in rel.components() {
match c {
Component::Normal(p) => comps.push(
CString::new(p.as_bytes())
.map_err(|_| Error::new(ErrorKind::InvalidInput, "NUL in path component"))?,
),
_ => {
return Err(Error::new(
ErrorKind::InvalidInput,
"target has a non-normal path component",
));
}
}
}
if comps.is_empty() {
return Err(Error::new(
ErrorKind::InvalidInput,
"target equals the root (no file component)",
));
}
let root_c = CString::new(root.as_os_str().as_bytes())
.map_err(|_| Error::new(ErrorKind::InvalidInput, "NUL in root path"))?;
let bytes = bytes.to_vec();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let fd = unsafe {
libc::open(
root_c.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(Error::last_os_error());
}
let mut parent = unsafe { OwnedFd::from_raw_fd(fd) };
let last = comps.len() - 1;
for (i, comp) in comps.iter().enumerate() {
if i == last {
let ffd = unsafe {
libc::openat(
parent.as_raw_fd(),
comp.as_ptr(),
libc::O_WRONLY
| libc::O_CREAT
| libc::O_TRUNC
| libc::O_NOFOLLOW
| libc::O_CLOEXEC,
0o644 as libc::c_uint,
)
};
if ffd < 0 {
return Err(Error::last_os_error());
}
let mut file = unsafe { std::fs::File::from_raw_fd(ffd) };
file.write_all(&bytes)?;
file.sync_all()?;
return Ok(());
}
let dfd = unsafe {
libc::openat(
parent.as_raw_fd(),
comp.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
if dfd < 0 {
return Err(Error::last_os_error());
}
parent = unsafe { OwnedFd::from_raw_fd(dfd) };
}
unreachable!("loop returns on the final component");
})
.await
.map_err(|e| std::io::Error::other(format!("spawn_blocking failed: {e}")))?
}
#[cfg(not(unix))]
pub(crate) async fn write_beneath_root(
_root: &std::path::Path,
target: &std::path::Path,
bytes: &[u8],
) -> std::io::Result<()> {
write_no_follow(target, bytes).await
}
pub(crate) fn path_param_description(workspace: Option<&std::path::Path>) -> &'static str {
if workspace.is_some() {
"Path relative to the workspace root (absolute paths are accepted only \
inside the workspace; outside paths are rejected — keep scratch files \
in the workspace, not /tmp)"
} else {
"Absolute path, or relative to the current directory"
}
}
pub(crate) fn resolve_path(
path: &str,
workspace: Option<&std::path::Path>,
protected_paths: &[PathBuf],
) -> Result<PathBuf, String> {
let p = std::path::Path::new(path);
match workspace {
Some(ws) => {
let candidate = if p.is_absolute() {
p.to_path_buf()
} else {
ws.join(p)
};
let normalized = crate::workspace::normalize_path(&candidate);
if !normalized.starts_with(ws) {
return Err(format!(
"Path '{path}' escapes the workspace root ({}). The file tools can only \
access paths INSIDE the workspace. Use a path inside it; for temporary \
or scratch work, create a subdirectory like ./scratch — that is your \
temporary directory (keep it out of tracked files).",
ws.display()
));
}
match normalized.canonicalize() {
Ok(canonical) => {
if !canonical.starts_with(ws) {
return Err(format!(
"Path '{path}' resolves to {} which is outside the workspace. Use a \
path inside the workspace; for temporary/scratch work, a \
subdirectory like ./scratch is your temporary directory.",
canonical.display()
));
}
}
Err(_) => {
for ancestor in normalized.ancestors() {
if ancestor.symlink_metadata().is_err() {
continue; }
match ancestor.canonicalize() {
Ok(canonical) if canonical.starts_with(ws) => break,
Ok(canonical) => {
return Err(format!(
"Path '{path}' resolves to {} which is outside the \
workspace. Use a path inside the workspace; for \
temporary/scratch work, a subdirectory like ./scratch is \
your temporary directory.",
canonical.display()
));
}
Err(_) => {
return Err(format!(
"Path '{path}' resolves through '{}' which cannot be \
verified inside the workspace. Use a path inside the \
workspace; for temporary/scratch work, a subdirectory \
like ./scratch is your temporary directory.",
ancestor.display()
));
}
}
}
}
}
if is_protected_resolved(&normalized, protected_paths) {
return Err(format!("Access to '{path}' is denied (protected path)."));
}
Ok(normalized)
}
None => {
let result = p.to_path_buf();
if is_protected_resolved(&result, protected_paths) {
return Err(format!("Access to '{path}' is denied (protected path)."));
}
Ok(result)
}
}
}
pub(crate) const SKIP_DIRS: &[&str] = &[
"target",
"node_modules",
"dist",
"build",
".git",
"__pycache__",
];
pub fn floor_char_boundary(text: &str, target: usize) -> usize {
let mut pos = target.min(text.len());
while pos > 0 && !text.is_char_boundary(pos) {
pos -= 1;
}
pos
}
pub use file_tracker::FileTracker;
pub use image_generate::ImageGenerateTool;
pub use question::{
OnQuestion, Question, QuestionOption, QuestionRequest, QuestionResponse, QuestionTool,
};
pub(crate) use todo::recite_open_todos;
pub use todo::{TodoPriority, TodoStatus, TodoStore, todo_tools};
#[cfg(feature = "ghost-domain-config")]
pub use twitter_post::TwitterCredentials;
pub use webfetch::WebFetchTool;
pub use websearch::{WebSearchTool, search_provider_label};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolRisk {
Safe,
Dangerous,
}
#[non_exhaustive]
pub struct BuiltinToolsConfig {
pub file_tracker: Arc<FileTracker>,
pub todo_store: Arc<TodoStore>,
pub on_question: Option<Arc<OnQuestion>>,
pub workspace: Option<PathBuf>,
pub dangerous_tools: bool,
pub env_policy: crate::workspace::EnvPolicy,
pub protected_paths: Vec<PathBuf>,
#[cfg(all(target_os = "linux", feature = "sandbox"))]
pub sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
#[cfg(feature = "ghost-domain-config")]
pub twitter_credentials: Option<TwitterCredentials>,
pub allowlist: Option<Vec<String>>,
pub path_policy: Option<Arc<crate::sandbox::CorePathPolicy>>,
pub skill_dirs: Vec<PathBuf>,
pub context_recall_store: Option<Arc<crate::agent::context_recall::ContextRecallStore>>,
}
pub fn default_protected_paths() -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = vec![
PathBuf::from("*.env"),
PathBuf::from("*.pem"),
PathBuf::from("*.key"),
PathBuf::from("*.p12"),
PathBuf::from("*.pfx"),
PathBuf::from("*.kdbx"),
PathBuf::from("/etc/shadow"),
PathBuf::from("/etc/sudoers"),
PathBuf::from("/proc/self/environ"),
];
if let Some(home) = std::env::var_os("HOME") {
let h = PathBuf::from(home);
v.push(h.join(".ssh"));
v.push(h.join(".aws"));
v.push(h.join(".gnupg"));
v.push(h.join(".config").join("heartbit"));
v.push(h.join(".docker").join("config.json"));
v.push(h.join(".netrc"));
}
v
}
impl Default for BuiltinToolsConfig {
fn default() -> Self {
Self {
file_tracker: Arc::new(FileTracker::new()),
todo_store: Arc::new(TodoStore::new()),
on_question: None,
workspace: None,
dangerous_tools: false,
env_policy: crate::workspace::EnvPolicy::default(),
protected_paths: default_protected_paths(),
#[cfg(all(target_os = "linux", feature = "sandbox"))]
sandbox_policy: None,
#[cfg(feature = "ghost-domain-config")]
twitter_credentials: None,
allowlist: None,
path_policy: None,
skill_dirs: Vec::new(),
context_recall_store: None,
}
}
}
pub fn builtin_tools(config: BuiltinToolsConfig) -> Vec<Arc<dyn Tool>> {
let ws = config.workspace.map(|w| w.canonicalize().unwrap_or(w));
let pp = Arc::new(config.protected_paths);
let path_policy = config.path_policy;
let mut tools: Vec<Arc<dyn Tool>> = Vec::new();
macro_rules! maybe_policy {
($tool:expr) => {
if let Some(ref pp) = path_policy {
$tool.with_path_policy(Arc::clone(pp))
} else {
$tool
}
};
}
if config.dangerous_tools {
let bash_tool: Arc<dyn Tool> = match &ws {
Some(path) => {
let tool = bash::BashTool::with_sandbox(path.clone(), config.env_policy);
#[cfg(all(target_os = "linux", feature = "sandbox"))]
let tool = if let Some(policy) = config.sandbox_policy {
tool.with_sandbox_policy(policy)
} else {
tool
};
Arc::new(maybe_policy!(tool))
}
None => Arc::new(maybe_policy!(bash::BashTool::new())),
};
tools.push(bash_tool);
}
tools.extend([
Arc::new(maybe_policy!(read::ReadTool::new(
config.file_tracker.clone(),
ws.clone(),
Arc::clone(&pp),
))) as Arc<dyn Tool>,
Arc::new(maybe_policy!(write::WriteTool::new(
config.file_tracker.clone(),
ws.clone(),
Arc::clone(&pp),
))),
Arc::new(maybe_policy!(edit::EditTool::new(
config.file_tracker.clone(),
ws.clone(),
Arc::clone(&pp),
))),
Arc::new(maybe_policy!(grep::GrepTool::new(
ws.clone(),
Arc::clone(&pp)
))),
Arc::new(maybe_policy!(glob::GlobTool::new(
ws.clone(),
Arc::clone(&pp)
))),
Arc::new(maybe_policy!(list::ListTool::new(
ws.clone(),
Arc::clone(&pp)
))),
Arc::new(maybe_policy!(patch::PatchTool::new(
config.file_tracker.clone(),
ws,
Arc::clone(&pp),
))),
Arc::new(webfetch::WebFetchTool::new()),
Arc::new(websearch::WebSearchTool::new()),
Arc::new(image_generate::ImageGenerateTool::new()),
Arc::new(tts::TtsTool::new()),
Arc::new(skill::SkillTool::with_dirs(config.skill_dirs.clone())),
]);
let todo_tools = todo::todo_tools(config.todo_store);
tools.extend(todo_tools);
if let Some(on_question) = config.on_question {
tools.push(Arc::new(question::QuestionTool::new(on_question)));
}
#[cfg(feature = "ghost-domain-config")]
if let Some(creds) = config.twitter_credentials {
tools.push(Arc::new(twitter_post::TwitterPostTool::new(creds)));
}
if let Some(store) = &config.context_recall_store {
tools.push(Arc::new(fetch_full_output::FetchFullOutputTool {
store: store.clone(),
}));
tools.push(Arc::new(recall_context::RecallContextTool {
store: store.clone(),
}));
}
if let Some(ref allowed) = config.allowlist {
let set: std::collections::HashSet<&str> = allowed.iter().map(|s| s.as_str()).collect();
tools.retain(|t| set.contains(t.definition().name.as_str()));
}
tools
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn write_beneath_root_writes_into_real_subdir() {
let root = tempfile::tempdir().unwrap();
let root_c = root.path().canonicalize().unwrap();
std::fs::create_dir(root_c.join("sub")).unwrap();
write_beneath_root(&root_c, &root_c.join("sub/ok.txt"), b"hi")
.await
.unwrap();
assert_eq!(std::fs::read(root_c.join("sub/ok.txt")).unwrap(), b"hi");
}
#[cfg(unix)]
#[tokio::test]
async fn write_beneath_root_refuses_symlinked_intermediate_component() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let root_c = root.path().canonicalize().unwrap();
let outside_c = outside.path().canonicalize().unwrap();
std::fs::create_dir(root_c.join("sub")).unwrap();
std::fs::remove_dir(root_c.join("sub")).unwrap();
std::os::unix::fs::symlink(&outside_c, root_c.join("sub")).unwrap();
let result = write_beneath_root(&root_c, &root_c.join("sub/evil.txt"), b"pwn").await;
assert!(
result.is_err(),
"write through a symlinked intermediate component must be refused"
);
assert!(
!outside_c.join("evil.txt").exists(),
"write escaped the root into the symlink target"
);
}
#[cfg(unix)]
#[tokio::test]
async fn write_beneath_root_refuses_symlinked_final_component() {
let root = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let root_c = root.path().canonicalize().unwrap();
let outside_c = outside.path().canonicalize().unwrap();
let victim = outside_c.join("victim.txt");
std::fs::write(&victim, b"original").unwrap();
std::os::unix::fs::symlink(&victim, root_c.join("link.txt")).unwrap();
let result = write_beneath_root(&root_c, &root_c.join("link.txt"), b"pwn").await;
assert!(result.is_err(), "write onto a symlink must be refused");
assert_eq!(
std::fs::read(&victim).unwrap(),
b"original",
"symlink target was overwritten"
);
}
#[test]
fn floor_char_boundary_ascii() {
assert_eq!(floor_char_boundary("hello", 3), 3);
assert_eq!(floor_char_boundary("hello", 10), 5);
assert_eq!(floor_char_boundary("hello", 0), 0);
}
#[test]
fn floor_char_boundary_multibyte() {
let s = "café";
assert_eq!(s.len(), 5);
assert_eq!(floor_char_boundary(s, 4), 3);
assert_eq!(floor_char_boundary(s, 3), 3);
assert_eq!(floor_char_boundary(s, 5), 5);
}
#[test]
fn resolve_path_absolute_inside_workspace_accepted() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let abs = ws.join("sub").join("notes.md");
let result = resolve_path(abs.to_str().unwrap(), Some(&ws), &[]);
assert_eq!(result.unwrap(), abs);
}
#[test]
fn resolve_path_absolute_outside_workspace_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("/etc/passwd", Some(&ws), &[]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("escapes the workspace"));
}
#[test]
fn resolve_path_rejection_redirects_to_in_workspace_scratch() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let err = resolve_path("/tmp/crm-project/main.rs", Some(&ws), &[]).unwrap_err();
assert!(
err.contains("scratch"),
"the outside-workspace rejection must name an in-workspace scratch \
destination, not just refuse: {err}"
);
}
#[test]
fn resolve_path_absolute_sneaky_traversal_still_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let sneaky = format!("{}/sub/../../etc/passwd", ws.display());
let result = resolve_path(&sneaky, Some(&ws), &[]);
assert!(result.is_err(), "got: {result:?}");
assert!(result.unwrap_err().contains("escapes the workspace"));
}
#[test]
fn resolve_path_absolute_passthrough_without_workspace() {
let result = resolve_path("/absolute/path", None, &[]);
assert_eq!(result.unwrap(), PathBuf::from("/absolute/path"));
}
#[cfg(unix)]
#[test]
fn resolve_path_symlink_to_protected_target_denied_in_workspace() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
std::fs::write(ws.join("secret.env"), "API_KEY=xxx").unwrap();
std::os::unix::fs::symlink(ws.join("secret.env"), ws.join("config.txt")).unwrap();
let protected = vec![PathBuf::from("*.env")];
let result = resolve_path("config.txt", Some(&ws), &protected);
assert!(
result.is_err(),
"symlink to a protected target must be denied, got {result:?}"
);
assert!(result.unwrap_err().contains("protected"));
}
#[cfg(unix)]
#[test]
fn resolve_path_symlink_to_protected_target_denied_no_workspace() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().canonicalize().unwrap();
std::fs::write(base.join("creds.pem"), "-----BEGIN KEY-----").unwrap();
let link = base.join("notes.txt");
std::os::unix::fs::symlink(base.join("creds.pem"), &link).unwrap();
let protected = vec![PathBuf::from("*.pem")];
let result = resolve_path(link.to_str().unwrap(), None, &protected);
assert!(
result.is_err(),
"symlink to a protected target must be denied without a workspace, got {result:?}"
);
assert!(result.unwrap_err().contains("protected"));
}
#[test]
fn resolve_path_relative_with_workspace() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("notes.md", Some(&ws), &[]);
assert_eq!(result.unwrap(), ws.join("notes.md"));
}
#[test]
fn resolve_path_relative_without_workspace() {
let result = resolve_path("notes.md", None, &[]);
assert_eq!(result.unwrap(), PathBuf::from("notes.md"));
}
#[test]
fn resolve_path_traversal_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("../../etc/passwd", Some(&ws), &[]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("escapes the workspace"));
}
#[test]
fn resolve_path_internal_dotdot_allowed() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("sub/../file.txt", Some(&ws), &[]);
assert_eq!(result.unwrap(), ws.join("file.txt"));
}
#[test]
fn resolve_path_boundary_dotdot_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("../escape", Some(&ws), &[]);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn resolve_path_new_file_through_symlinked_intermediate_dir_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_c = outside.path().canonicalize().unwrap();
std::os::unix::fs::symlink(&outside_c, ws.join("data")).unwrap();
let result = resolve_path("data/evil.txt", Some(&ws), &[]);
assert!(
result.is_err(),
"new-file create through a symlinked intermediate dir must be rejected: {result:?}"
);
assert!(
result.unwrap_err().contains("outside the workspace"),
"rejection should use the jail wording"
);
}
#[cfg(unix)]
#[test]
fn resolve_path_new_file_deep_under_symlinked_dir_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let outside = tempfile::tempdir().unwrap();
let outside_c = outside.path().canonicalize().unwrap();
std::fs::create_dir(outside_c.join("sub")).unwrap();
std::os::unix::fs::symlink(&outside_c, ws.join("data")).unwrap();
let result = resolve_path("data/sub/evil.txt", Some(&ws), &[]);
assert!(
result.is_err(),
"deep new-file create through a symlinked dir must be rejected: {result:?}"
);
}
#[cfg(unix)]
#[test]
fn resolve_path_new_file_through_dangling_symlink_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
std::os::unix::fs::symlink("/nonexistent_heartbit_jail_test", ws.join("data")).unwrap();
let result = resolve_path("data/evil.txt", Some(&ws), &[]);
assert!(
result.is_err(),
"new-file create through a dangling symlink must fail closed: {result:?}"
);
}
#[test]
fn resolve_path_new_file_in_nonexistent_real_subdir_allowed() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let result = resolve_path("newdir/sub/file.txt", Some(&ws), &[]);
assert_eq!(result.unwrap(), ws.join("newdir/sub/file.txt"));
}
#[test]
fn resolve_path_symlink_escape_rejected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let target = tempfile::tempdir().unwrap();
std::fs::write(target.path().join("secret.txt"), "secret").unwrap();
let link_path = ws.join("escape_link");
#[cfg(unix)]
std::os::unix::fs::symlink(target.path(), &link_path).unwrap();
#[cfg(not(unix))]
{
return;
}
let result = resolve_path("escape_link/secret.txt", Some(&ws), &[]);
assert!(
result.is_err(),
"symlink escape should be rejected: {:?}",
result
);
}
#[test]
fn resolve_path_rejects_protected_extension() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
std::fs::write(ws.join("secret.env"), "SECRET=value").unwrap();
let protected = vec![PathBuf::from("*.env")];
let result = resolve_path("secret.env", Some(&ws), &protected);
assert!(result.is_err());
assert!(result.unwrap_err().contains("protected"));
}
#[test]
fn resolve_path_allows_non_protected() {
let dir = tempfile::tempdir().unwrap();
let ws = dir.path().canonicalize().unwrap();
let protected = vec![PathBuf::from("*.env")];
let result = resolve_path("notes.md", Some(&ws), &protected);
assert!(result.is_ok());
}
#[test]
fn builtin_tools_excludes_bash_by_default() {
let tools = builtin_tools(BuiltinToolsConfig::default());
assert!(!tools.iter().any(|t| t.definition().name == "bash"));
assert_eq!(tools.len(), 14);
}
#[test]
fn builtin_tools_includes_bash_when_dangerous() {
let config = BuiltinToolsConfig {
dangerous_tools: true,
..Default::default()
};
let tools = builtin_tools(config);
assert!(tools.iter().any(|t| t.definition().name == "bash"));
assert_eq!(tools.len(), 15);
}
#[test]
fn builtin_tools_with_question_callback() {
let config = BuiltinToolsConfig {
dangerous_tools: true,
on_question: Some(Arc::new(|_| {
Box::pin(async { Ok(QuestionResponse { answers: vec![] }) })
})),
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 16);
}
#[cfg(feature = "ghost-domain-config")]
#[test]
fn builtin_tools_includes_twitter_when_credentials_present() {
let config = BuiltinToolsConfig {
twitter_credentials: Some(TwitterCredentials {
consumer_key: "ck".into(),
consumer_secret: "cs".into(),
access_token: "at".into(),
access_token_secret: "ats".into(),
}),
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 15); assert!(tools.iter().any(|t| t.definition().name == "twitter_post"));
}
#[cfg(feature = "ghost-domain-config")]
#[test]
fn builtin_tools_excludes_twitter_when_no_credentials() {
let tools = builtin_tools(BuiltinToolsConfig::default());
assert!(!tools.iter().any(|t| t.definition().name == "twitter_post"));
}
#[test]
fn builtin_tools_with_allowlist() {
let config = BuiltinToolsConfig {
allowlist: Some(vec!["websearch".into(), "webfetch".into()]),
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 2);
let names: Vec<String> = tools.iter().map(|t| t.definition().name.clone()).collect();
assert!(names.contains(&"websearch".to_string()));
assert!(names.contains(&"webfetch".to_string()));
}
#[test]
fn builtin_tools_empty_allowlist() {
let config = BuiltinToolsConfig {
allowlist: Some(vec![]),
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 0);
}
#[test]
fn builtin_tools_allowlist_none_returns_all() {
let config = BuiltinToolsConfig {
allowlist: None,
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 14);
}
#[test]
fn builtin_tools_allowlist_bash_gated() {
let config = BuiltinToolsConfig {
dangerous_tools: false,
allowlist: Some(vec!["bash".into()]),
..Default::default()
};
let tools = builtin_tools(config);
assert_eq!(tools.len(), 0);
}
#[test]
fn context_recall_tools_registered_only_when_store_present() {
use std::sync::Arc;
let off = builtin_tools(BuiltinToolsConfig::default());
assert!(
!off.iter()
.any(|t| t.definition().name == "fetch_full_output")
);
assert!(!off.iter().any(|t| t.definition().name == "recall_context"));
let cfg = BuiltinToolsConfig {
context_recall_store: Some(Arc::new(
crate::agent::context_recall::ContextRecallStore::new(),
)),
..Default::default()
};
let on = builtin_tools(cfg);
assert!(
on.iter()
.any(|t| t.definition().name == "fetch_full_output")
);
assert!(on.iter().any(|t| t.definition().name == "recall_context"));
}
}