use std::collections::HashMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::SystemTime;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use unicode_normalization::UnicodeNormalization;
use crate::features::Features;
use crate::lsp::LspManager;
use crate::network_policy::NetworkPolicyDecider;
use crate::rlm::session::SessionObjectSnapshot;
use crate::rlm::session::{SharedRlmSessionStore, new_shared_rlm_session_store};
use crate::sandbox::backend::SandboxBackend;
use crate::tools::handle::{SharedHandleStore, new_shared_handle_store};
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
use crate::worker_profile::ShellPolicy;
#[allow(unused_imports)]
pub use codewhale_tools::{
ApprovalRequirement, PreparedToolCall, ResourceClaim, ToolCapability, ToolError,
ToolExecutionOutcome, ToolResult, ToolTerminalStatus, optional_bool, optional_str,
optional_u64, required_str, required_u64, schedule_non_conflicting,
};
#[async_trait]
pub trait DynamicToolExecutor: Send + Sync {
async fn execute_dynamic_tool(
&self,
thread_id: Option<String>,
namespace: Option<String>,
name: String,
input: Value,
) -> Result<ToolResult, ToolError>;
}
#[derive(Clone)]
pub struct RuntimeToolServices {
pub shell_manager: Option<SharedShellManager>,
pub task_manager: Option<crate::task_manager::SharedTaskManager>,
pub automations: Option<crate::automation_manager::SharedAutomationManager>,
pub task_data_dir: Option<PathBuf>,
pub active_task_id: Option<String>,
pub active_thread_id: Option<String>,
pub dynamic_tool_executor: Option<Arc<dyn DynamicToolExecutor>>,
pub work: Option<crate::work_graph::SharedWorkRuntime>,
pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
pub handle_store: SharedHandleStore,
pub rlm_sessions: SharedRlmSessionStore,
}
impl Default for RuntimeToolServices {
fn default() -> Self {
Self {
shell_manager: None,
task_manager: None,
automations: None,
task_data_dir: None,
active_task_id: None,
active_thread_id: None,
dynamic_tool_executor: None,
work: None,
hook_executor: None,
handle_store: new_shared_handle_store(),
rlm_sessions: new_shared_rlm_session_store(),
}
}
}
impl std::fmt::Debug for RuntimeToolServices {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeToolServices")
.field("shell_manager", &self.shell_manager.is_some())
.field("task_manager", &self.task_manager.is_some())
.field("automations", &self.automations.is_some())
.field("task_data_dir", &self.task_data_dir)
.field("active_task_id", &self.active_task_id)
.field("active_thread_id", &self.active_thread_id)
.field(
"dynamic_tool_executor",
&self.dynamic_tool_executor.is_some(),
)
.field("work", &self.work.is_some())
.field("hook_executor", &self.hook_executor.is_some())
.field("handle_store", &true)
.field("rlm_sessions", &true)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileReadSnapshot {
len: u64,
modified: Option<SystemTime>,
}
#[derive(Debug, Default)]
pub struct FileReadTracker {
reads: HashMap<PathBuf, FileReadSnapshot>,
}
pub type SharedFileReadTracker = Arc<Mutex<FileReadTracker>>;
pub(crate) fn new_shared_file_read_tracker() -> SharedFileReadTracker {
Arc::new(Mutex::new(FileReadTracker::default()))
}
fn file_read_snapshot(path: &Path) -> Result<FileReadSnapshot, ToolError> {
let metadata = fs::metadata(path).map_err(|e| {
ToolError::execution_failed(format!("Failed to inspect {}: {e}", path.display()))
})?;
Ok(FileReadSnapshot {
len: metadata.len(),
modified: metadata.modified().ok(),
})
}
#[derive(Debug, Clone, Default)]
pub enum SandboxPolicy {
#[default]
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolAuthorityEnvelope {
pub schema_version: u32,
pub owner: String,
pub authority: ToolMutationAuthority,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_access: Option<bool>,
#[serde(default)]
pub writable_roots: Vec<String>,
#[serde(default)]
pub writable_files: Vec<String>,
#[serde(default)]
pub coordination_contracts: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolMutationAuthority {
ReadOnly,
ScopedWrite,
}
static PROCESS_TOOL_AUTHORITY: OnceLock<Arc<ToolAuthorityEnvelope>> = OnceLock::new();
impl ToolAuthorityEnvelope {
pub fn normalized(mut self) -> Result<Self, String> {
if self.schema_version != 1 {
return Err(format!(
"unsupported tool authority schema version {}",
self.schema_version
));
}
self.owner = bounded_authority_value("owner", &self.owner, 128)?;
self.writable_roots = normalize_authority_paths(&self.writable_roots, "writable_roots")?;
self.writable_files = normalize_authority_paths(&self.writable_files, "writable_files")?;
self.coordination_contracts = normalize_authority_values(
&self.coordination_contracts,
"coordination_contracts",
16,
128,
)?;
if self.authority == ToolMutationAuthority::ScopedWrite
&& self.writable_roots.is_empty()
&& self.writable_files.is_empty()
&& self.coordination_contracts.is_empty()
{
return Err(
"scoped_write authority requires a writable root, exact file, or coordination contract"
.to_string(),
);
}
if self.authority == ToolMutationAuthority::ReadOnly
&& (!self.writable_roots.is_empty()
|| !self.writable_files.is_empty()
|| !self.coordination_contracts.is_empty())
{
return Err("read_only authority cannot carry mutation scope".to_string());
}
Ok(self)
}
pub fn from_json(raw: &str) -> Result<Self, String> {
serde_json::from_str::<Self>(raw)
.map_err(|error| format!("invalid tool authority envelope: {error}"))?
.normalized()
}
#[cfg(test)]
fn is_within(&self, outer: &Self) -> bool {
if self.authority == ToolMutationAuthority::ReadOnly {
return true;
}
if outer.authority != ToolMutationAuthority::ScopedWrite {
return false;
}
self.writable_roots.iter().all(|path| {
outer
.writable_roots
.iter()
.any(|root| authority_path_is_within_root(path, root))
}) && self.writable_files.iter().all(|path| {
outer.writable_files.contains(path)
|| outer
.writable_roots
.iter()
.any(|root| authority_path_is_within_root(path, root))
}) && self
.coordination_contracts
.iter()
.all(|contract| outer.coordination_contracts.contains(contract))
}
pub fn permits_mutation_path(
&self,
context: &ToolContext,
raw_path: &str,
) -> Result<bool, ToolError> {
if self.authority == ToolMutationAuthority::ReadOnly {
return Ok(false);
}
let target = resolve_strict_authority_path(context, raw_path)?;
for file in &self.writable_files {
if resolve_strict_authority_path(context, file)? == target {
return Ok(true);
}
}
for root in &self.writable_roots {
if target.starts_with(resolve_strict_authority_path(context, root)?) {
return Ok(true);
}
}
Ok(false)
}
}
#[cfg(test)]
fn authority_path_is_within_root(path: &str, root: &str) -> bool {
root == "."
|| path == root
|| path
.strip_prefix(root)
.is_some_and(|suffix| suffix.starts_with('/'))
}
pub fn install_process_tool_authority(envelope: ToolAuthorityEnvelope) -> Result<(), String> {
let envelope = Arc::new(envelope.normalized()?);
if let Some(existing) = PROCESS_TOOL_AUTHORITY.get() {
return if existing.as_ref() == envelope.as_ref() {
Ok(())
} else {
Err("tool authority envelope was already installed for this process".to_string())
};
}
PROCESS_TOOL_AUTHORITY
.set(envelope)
.map_err(|_| "tool authority envelope was already installed for this process".to_string())
}
fn process_tool_authority() -> Option<Arc<ToolAuthorityEnvelope>> {
PROCESS_TOOL_AUTHORITY.get().cloned()
}
fn bounded_authority_value(field: &str, value: &str, max_chars: usize) -> Result<String, String> {
let value = value.trim().nfc().collect::<String>();
if value.is_empty()
|| value.chars().count() > max_chars
|| value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
{
return Err(format!(
"tool authority {field} must be one non-empty line of at most {max_chars} characters"
));
}
Ok(value)
}
fn normalize_authority_paths(values: &[String], field: &str) -> Result<Vec<String>, String> {
if values.len() > 32 {
return Err(format!("tool authority {field} accepts at most 32 entries"));
}
let mut normalized = Vec::new();
for raw in values {
let raw = bounded_authority_value(field, raw, 512)?.replace('\\', "/");
let windows_drive = raw.as_bytes().get(1) == Some(&b':')
&& raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if raw.starts_with('/') || raw.starts_with("//") || windows_drive {
return Err(format!(
"tool authority {field} entries must be repo-relative"
));
}
let mut segments = Vec::new();
for segment in raw.split('/') {
match segment {
"" | "." => {}
".." => {
return Err(format!(
"tool authority {field} cannot contain parent traversal"
));
}
value => segments.push(value),
}
}
let path = if segments.is_empty() {
".".to_string()
} else {
segments.join("/")
};
if !normalized.contains(&path) {
normalized.push(path);
}
}
Ok(normalized)
}
fn normalize_authority_values(
values: &[String],
field: &str,
max_entries: usize,
max_chars: usize,
) -> Result<Vec<String>, String> {
if values.len() > max_entries {
return Err(format!(
"tool authority {field} accepts at most {max_entries} entries"
));
}
let mut normalized = Vec::new();
for value in values {
let value = bounded_authority_value(field, value, max_chars)?;
if !normalized.contains(&value) {
normalized.push(value);
}
}
Ok(normalized)
}
pub(crate) fn resolve_strict_authority_path(
context: &ToolContext,
raw_path: &str,
) -> Result<PathBuf, ToolError> {
let normalized = normalize_authority_paths(&[raw_path.to_string()], "mutation_path")
.map_err(ToolError::permission_denied)?
.into_iter()
.next()
.ok_or_else(|| ToolError::permission_denied("mutation path cannot be empty"))?;
let workspace = context.workspace.canonicalize().map_err(|error| {
ToolError::execution_failed(format!(
"Failed to canonicalize authority workspace {}: {error}",
context.workspace.display()
))
})?;
let mut current = workspace.clone();
if normalized != "." {
for segment in normalized.split('/') {
current.push(segment);
match fs::symlink_metadata(¤t) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(ToolError::permission_denied(format!(
"machine-readable authority paths must not traverse symlinks: {}",
current.display()
)));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(ToolError::execution_failed(format!(
"Failed to inspect authority path {}: {error}",
current.display()
)));
}
}
}
}
if !current.starts_with(&workspace) {
return Err(ToolError::permission_denied(format!(
"machine-readable authority path escapes workspace: {}",
current.display()
)));
}
Ok(current)
}
#[derive(Clone)]
pub struct ToolContext {
pub workspace: PathBuf,
pub execution: Box<ToolExecutionState>,
}
#[derive(Clone)]
pub struct ToolExecutionState {
pub shell_manager: SharedShellManager,
pub file_read_tracker: SharedFileReadTracker,
pub owner_agent_id: Option<String>,
pub owner_agent_name: Option<String>,
pub(crate) tool_authority: Option<Arc<ToolAuthorityEnvelope>>,
pub trust_mode: bool,
#[allow(dead_code)]
pub sandbox_policy: SandboxPolicy,
pub notes_path: PathBuf,
#[allow(dead_code)]
pub mcp_config_path: PathBuf,
pub skills_dir: Option<PathBuf>,
pub skills_scan_codewhale_only: bool,
pub plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
pub elevated_sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
pub shell_network_denied_hint: Option<String>,
pub auto_approve: bool,
pub shell_policy: ShellPolicy,
pub features: Features,
pub state_namespace: String,
pub route_context_window: Option<u32>,
pub trusted_external_paths: Vec<PathBuf>,
pub follow_symlinks: bool,
pub network_policy: Option<NetworkPolicyDecider>,
pub runtime: RuntimeToolServices,
pub session_objects: Option<SessionObjectSnapshot>,
pub cancel_token: Option<CancellationToken>,
pub sandbox_backend: Option<std::sync::Arc<dyn SandboxBackend>>,
pub memory_path: Option<PathBuf>,
pub lsp_manager: Option<Arc<LspManager>>,
pub large_output_router: Option<crate::tools::large_output_router::LargeOutputRouter>,
pub search_provider: crate::config::SearchProvider,
pub search_api_key: Option<String>,
pub search_base_url: Option<String>,
pub(crate) provider_native_search: Option<crate::client::ProviderNativeSearchClient>,
pub(crate) route_capabilities: codewhale_config::route::RouteCapabilities,
pub workshop_vars: Option<
std::sync::Arc<tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>>,
>,
}
impl std::ops::Deref for ToolContext {
type Target = ToolExecutionState;
fn deref(&self) -> &Self::Target {
&self.execution
}
}
impl std::ops::DerefMut for ToolContext {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.execution
}
}
impl ToolContext {
#[must_use]
pub fn new(workspace: impl Into<PathBuf>) -> Self {
let workspace = workspace.into();
let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md")
.expect("hardcoded project notes state path is valid")
.1;
let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json")
.expect("hardcoded project MCP state path is valid")
.1;
Self::with_options(workspace, false, notes_path, mcp_config_path)
}
#[allow(dead_code)]
pub fn with_options(
workspace: impl Into<PathBuf>,
trust_mode: bool,
notes_path: impl Into<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
) -> Self {
let workspace = workspace.into();
let shell_manager = new_shared_shell_manager(workspace.clone());
Self {
workspace,
execution: Box::new(ToolExecutionState {
shell_manager,
file_read_tracker: new_shared_file_read_tracker(),
owner_agent_id: None,
owner_agent_name: None,
tool_authority: process_tool_authority(),
trust_mode,
sandbox_policy: SandboxPolicy::None,
notes_path: notes_path.into(),
mcp_config_path: mcp_config_path.into(),
skills_dir: None,
skills_scan_codewhale_only: false,
plugin_registry: None,
elevated_sandbox_policy: None,
shell_network_denied_hint: None,
auto_approve: false,
shell_policy: ShellPolicy::Full,
features: Features::with_defaults(),
state_namespace: "workspace".to_string(),
route_context_window: None,
trusted_external_paths: Vec::new(),
follow_symlinks: false,
network_policy: None,
runtime: RuntimeToolServices::default(),
session_objects: None,
cancel_token: None,
sandbox_backend: None,
memory_path: None,
lsp_manager: None,
large_output_router: None,
search_provider: crate::config::SearchProvider::default(),
search_api_key: None,
search_base_url: None,
provider_native_search: None,
route_capabilities: codewhale_config::route::RouteCapabilities::default(),
workshop_vars: None,
}),
}
}
pub fn with_auto_approve(
workspace: impl Into<PathBuf>,
trust_mode: bool,
notes_path: impl Into<PathBuf>,
mcp_config_path: impl Into<PathBuf>,
auto_approve: bool,
) -> Self {
let mut context = Self::with_options(workspace, trust_mode, notes_path, mcp_config_path);
context.auto_approve = auto_approve;
context
}
#[must_use]
pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
self.network_policy = Some(policy);
self
}
#[must_use]
pub fn with_runtime_services(mut self, runtime: RuntimeToolServices) -> Self {
self.runtime = runtime;
self
}
#[must_use]
pub fn with_owner_agent(
mut self,
agent_id: impl Into<String>,
agent_name: impl Into<String>,
) -> Self {
let agent_id = agent_id.into();
let agent_name = agent_name.into();
self.owner_agent_id = (!agent_id.trim().is_empty()).then_some(agent_id);
self.owner_agent_name = (!agent_name.trim().is_empty()).then_some(agent_name);
self
}
#[cfg(test)]
pub(crate) fn with_tool_authority(
mut self,
envelope: ToolAuthorityEnvelope,
) -> Result<Self, String> {
let envelope = envelope.normalized()?;
if let Some(outer) = self.tool_authority.as_ref()
&& !envelope.is_within(outer)
{
return Err(
"nested tool authority cannot expand its process authority cap".to_string(),
);
}
self.tool_authority = Some(Arc::new(envelope));
Ok(self)
}
#[must_use]
pub fn with_skills_config(
mut self,
skills_dir: impl Into<PathBuf>,
scan_codewhale_only: bool,
) -> Self {
self.skills_dir = Some(skills_dir.into());
self.skills_scan_codewhale_only = scan_codewhale_only;
self
}
#[must_use]
pub fn with_plugin_registry(mut self, registry: Arc<crate::plugins::PluginRegistry>) -> Self {
self.plugin_registry = Some(registry);
self
}
#[must_use]
pub fn with_session_objects(mut self, snapshot: SessionObjectSnapshot) -> Self {
self.session_objects = Some(snapshot);
self
}
#[must_use]
pub fn with_cancel_token(mut self, cancel_token: CancellationToken) -> Self {
self.cancel_token = Some(cancel_token);
self
}
#[must_use]
pub fn with_shell_policy(mut self, policy: ShellPolicy) -> Self {
self.shell_policy = policy;
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_sandbox_backend(mut self, backend: std::sync::Arc<dyn SandboxBackend>) -> Self {
self.sandbox_backend = Some(backend);
self
}
#[must_use]
pub fn with_trusted_external_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.trusted_external_paths = paths;
self
}
#[must_use]
pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
self.follow_symlinks = follow;
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_lsp_manager(mut self, manager: Arc<LspManager>) -> Self {
self.lsp_manager = Some(manager);
self
}
pub fn note_file_read(&self, path: &Path) {
let Ok(snapshot) = file_read_snapshot(path) else {
return;
};
let Ok(mut tracker) = self.file_read_tracker.lock() else {
return;
};
tracker.reads.insert(path.to_path_buf(), snapshot);
}
pub fn require_fresh_file_read(
&self,
path: &Path,
requested_path: &str,
) -> Result<(), ToolError> {
let prior = {
let tracker = self.file_read_tracker.lock().map_err(|_| {
ToolError::execution_failed(
"Failed to check read-before-edit state: tracker lock poisoned".to_string(),
)
})?;
tracker.reads.get(path).cloned()
};
let Some(prior) = prior else {
return Err(ToolError::execution_failed(format!(
"Refusing edit_file for {} because it has not been read in this session. \
Recovery: call read_file with path=\"{requested_path}\" to inspect the current contents, \
then retry edit_file with a unique search string.",
path.display()
)));
};
let current = file_read_snapshot(path).map_err(|e| {
ToolError::execution_failed(format!(
"Refusing edit_file for {} because the file could not be checked for staleness ({e}). \
Recovery: call read_file with path=\"{requested_path}\" again, then retry edit_file.",
path.display()
))
})?;
if current != prior {
return Err(ToolError::execution_failed(format!(
"Refusing edit_file for {} because it changed since the last read_file call. \
Recovery: call read_file with path=\"{requested_path}\" again and retry with the current contents.",
path.display()
)));
}
Ok(())
}
pub fn resolve_path(&self, raw: &str) -> Result<PathBuf, ToolError> {
let candidate = if std::path::Path::new(raw).is_absolute() {
PathBuf::from(raw)
} else {
self.workspace.join(raw)
};
if self.trust_mode {
return Ok(candidate.canonicalize().unwrap_or(candidate));
}
let workspace_canonical = self
.workspace
.canonicalize()
.unwrap_or_else(|_| self.workspace.clone());
if self.follow_symlinks {
let candidate_normalized = normalize_path(&candidate);
let workspace_normalized = normalize_path(&self.workspace);
let workspace_canonical_normalized = normalize_path(&workspace_canonical);
if candidate_normalized.starts_with(&workspace_normalized)
|| candidate_normalized.starts_with(&workspace_canonical_normalized)
{
if candidate.exists() {
return Ok(candidate.canonicalize().unwrap_or(candidate));
}
return self.resolve_nonexistent_path(candidate, &workspace_canonical);
}
}
let candidate_canonical = candidate
.canonicalize()
.unwrap_or_else(|_| normalize_path(&candidate));
let workspace_normalized = normalize_path(&workspace_canonical);
if !candidate_canonical.starts_with(&workspace_normalized) {
let workspace_plain = normalize_path(&self.workspace);
let candidate_normalized = normalize_path(&candidate);
if !candidate_normalized.starts_with(&workspace_plain)
&& !self.is_trusted_external_path(&candidate_canonical)
&& !self.is_trusted_external_path(&candidate_normalized)
{
return Err(ToolError::PathEscape {
path: candidate_canonical,
});
}
}
if candidate.exists() {
let canonical = candidate.canonicalize().map_err(|e| {
ToolError::execution_failed(format!(
"Failed to canonicalize {}: {}",
candidate.display(),
e
))
})?;
if !canonical.starts_with(&workspace_canonical)
&& !self.is_trusted_external_path(&canonical)
{
return Err(ToolError::PathEscape { path: canonical });
}
return Ok(canonical);
}
self.resolve_nonexistent_path(candidate, &workspace_canonical)
}
fn resolve_nonexistent_path(
&self,
candidate: PathBuf,
workspace_canonical: &Path,
) -> Result<PathBuf, ToolError> {
let workspace_normalized = normalize_path(workspace_canonical);
let workspace_plain = normalize_path(&self.workspace);
let mut existing_ancestor = candidate.clone();
let mut suffix_parts: Vec<std::ffi::OsString> = Vec::new();
while !existing_ancestor.exists() {
if let Some(file_name) = existing_ancestor.file_name() {
suffix_parts.push(file_name.to_owned());
}
match existing_ancestor.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
existing_ancestor = parent.to_path_buf();
}
_ => {
break;
}
}
}
let ancestor_normalized = normalize_path(&existing_ancestor);
let canonical_ancestor = if existing_ancestor.exists() {
existing_ancestor
.canonicalize()
.unwrap_or(existing_ancestor)
} else {
existing_ancestor
};
let mut canonical = canonical_ancestor;
for part in suffix_parts.into_iter().rev() {
canonical.push(part);
}
let canonical = normalize_path(&canonical);
if self.follow_symlinks
&& (ancestor_normalized.starts_with(&workspace_plain)
|| ancestor_normalized.starts_with(&workspace_normalized))
{
return Ok(canonical);
}
if !canonical.starts_with(workspace_canonical)
&& !canonical.starts_with(&workspace_normalized)
&& !self.is_trusted_external_path(&canonical)
{
return Err(ToolError::PathEscape { path: canonical });
}
Ok(canonical)
}
fn is_trusted_external_path(&self, path: &Path) -> bool {
self.trusted_external_paths
.iter()
.any(|trusted| path.starts_with(trusted))
}
#[allow(dead_code)]
pub fn with_trust_mode(mut self, trust: bool) -> Self {
self.trust_mode = trust;
self
}
#[allow(dead_code)]
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
self.sandbox_policy = policy;
self
}
pub fn with_features(mut self, features: Features) -> Self {
self.features = features;
self
}
pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
self.shell_manager = shell_manager;
self
}
pub fn with_file_read_tracker(mut self, tracker: SharedFileReadTracker) -> Self {
self.file_read_tracker = tracker;
self
}
pub fn with_elevated_sandbox_policy(mut self, policy: crate::sandbox::SandboxPolicy) -> Self {
self.elevated_sandbox_policy = Some(policy);
self
}
pub fn with_shell_network_denied_hint(mut self, hint: impl Into<String>) -> Self {
self.shell_network_denied_hint = Some(hint.into());
self
}
pub fn with_state_namespace(mut self, namespace: impl Into<String>) -> Self {
self.state_namespace = namespace.into();
self
}
#[must_use]
pub fn with_route_context_window(mut self, context_window: u32) -> Self {
self.route_context_window = (context_window > 0).then_some(context_window);
self
}
#[must_use]
pub fn with_large_output_router(
mut self,
router: crate::tools::large_output_router::LargeOutputRouter,
vars: std::sync::Arc<
tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>,
>,
) -> Self {
self.large_output_router = Some(router);
self.workshop_vars = Some(vars);
self
}
}
pub async fn lsp_diagnostics_for_paths(context: &ToolContext, paths: &[PathBuf]) -> String {
use crate::lsp::render_blocks;
let manager = match context.lsp_manager.as_ref() {
Some(m) if m.config().enabled => m,
_ => return String::new(),
};
let mut blocks = Vec::new();
for (idx, path) in paths.iter().enumerate() {
if let Some(block) = manager.diagnostics_for(path, idx as u64).await {
blocks.push(block);
}
}
render_blocks(&blocks)
}
fn normalize_path(path: &Path) -> PathBuf {
let mut prefix: Option<std::ffi::OsString> = None;
let mut is_root = false;
let mut stack: Vec<std::ffi::OsString> = Vec::new();
for component in path.components() {
match component {
Component::Prefix(prefix_component) => {
prefix = Some(prefix_component.as_os_str().to_owned());
}
Component::RootDir => {
is_root = true;
}
Component::CurDir => {}
Component::ParentDir => {
let parent = Component::ParentDir.as_os_str();
if let Some(last) = stack.pop() {
if last == parent {
stack.push(last);
stack.push(parent.to_owned());
}
} else if !is_root {
stack.push(parent.to_owned());
}
}
Component::Normal(part) => {
stack.push(part.to_owned());
}
}
}
let mut normalized = PathBuf::new();
if let Some(prefix) = prefix {
normalized.push(prefix);
}
if is_root {
normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR));
}
for part in stack {
normalized.push(part);
}
normalized
}
#[async_trait]
pub trait ToolSpec: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> Value;
fn capabilities(&self) -> Vec<ToolCapability>;
fn approval_requirement(&self) -> ApprovalRequirement {
let caps = self.capabilities();
if caps.contains(&ToolCapability::ExecutesCode) {
ApprovalRequirement::Required
} else if caps.contains(&ToolCapability::WritesFiles) {
ApprovalRequirement::Suggest
} else {
ApprovalRequirement::Auto
}
}
fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
self.approval_requirement()
}
#[allow(dead_code)]
fn is_sandboxable(&self) -> bool {
self.capabilities().contains(&ToolCapability::Sandboxable)
}
fn is_read_only(&self) -> bool {
let caps = self.capabilities();
caps.contains(&ToolCapability::ReadOnly)
&& !caps.contains(&ToolCapability::WritesFiles)
&& !caps.contains(&ToolCapability::ExecutesCode)
}
fn is_read_only_for(&self, _input: &Value) -> bool {
self.is_read_only()
}
fn supports_parallel(&self) -> bool {
false
}
fn supports_parallel_for(&self, _input: &Value) -> bool {
self.supports_parallel()
}
fn starts_detached_for(&self, _input: &Value) -> bool {
false
}
fn prepare(&self, input: Value, _context: &ToolContext) -> Result<PreparedToolCall, ToolError> {
Ok(PreparedToolCall {
name: self.name().to_string(),
description: self.description().to_string(),
read_only: self.is_read_only_for(&input),
supports_parallel: self.supports_parallel_for(&input),
starts_detached: self.starts_detached_for(&input),
approval: self.approval_requirement_for(&input),
resources: vec![ResourceClaim::GlobalExclusive],
input,
})
}
fn defer_loading(&self) -> bool {
false
}
fn model_visible(&self) -> bool {
true
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError>;
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[cfg(unix)]
use std::os::unix::fs::symlink;
use tempfile::tempdir;
#[test]
fn test_tool_result_success() {
let result = ToolResult::success("hello");
assert!(result.success);
assert_eq!(result.content, "hello");
assert!(result.metadata.is_none());
}
#[test]
fn test_tool_result_error() {
let result = ToolResult::error("something failed");
assert!(!result.success);
assert_eq!(result.content, "something failed");
}
#[test]
fn test_tool_result_json() {
let data = json!({"key": "value"});
let result = ToolResult::json(&data).unwrap();
assert!(result.success);
assert!(result.content.contains("key"));
}
#[test]
fn test_tool_result_with_metadata() {
let result = ToolResult::success("content").with_metadata(json!({"extra": true}));
assert!(result.metadata.is_some());
}
#[test]
fn test_tool_context_resolve_path_relative() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let test_file = tmp.path().join("test.txt");
std::fs::write(&test_file, "test").expect("write");
let resolved = ctx.resolve_path("test.txt").expect("resolve");
assert!(resolved.ends_with("test.txt"));
}
#[test]
fn test_tool_context_resolve_path_escape() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("/etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_parent_traversal() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("../escape.txt");
assert!(result.is_err());
}
#[test]
fn test_tool_context_resolve_path_normalizes_parent() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = ctx.resolve_path("new/../safe.txt");
assert!(result.is_ok());
}
#[test]
fn test_tool_context_trust_mode() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf()).with_trust_mode(true);
let result = ctx.resolve_path("/tmp");
assert!(result.is_ok());
}
#[test]
fn tool_context_keeps_execution_state_grouped_and_value_cloned() {
let mut context = ToolContext::new(".");
context.auto_approve = true;
context.state_namespace = "session-a".to_string();
assert!(context.execution.auto_approve);
assert_eq!(context.execution.state_namespace, "session-a");
let mut cloned = context.clone();
cloned.state_namespace = "session-b".to_string();
assert_eq!(context.state_namespace, "session-a");
assert_eq!(cloned.execution.state_namespace, "session-b");
}
#[test]
fn tool_context_top_level_stays_slim_as_services_grow() {
assert!(
std::mem::size_of::<ToolContext>()
<= std::mem::size_of::<PathBuf>() + 2 * std::mem::size_of::<usize>(),
"ToolContext should contain only the workspace and boxed execution group"
);
}
#[test]
fn test_tool_context_trusted_external_path_allows_escape() {
let workspace = tempdir().expect("workspace tempdir");
let trusted_root = tempdir().expect("trusted tempdir");
let trusted_file = trusted_root.path().join("notes.md");
std::fs::write(&trusted_file, "shared notes").unwrap();
let ctx =
ToolContext::new(workspace.path().to_path_buf()).with_trusted_external_paths(vec![
trusted_root
.path()
.canonicalize()
.unwrap_or_else(|_| trusted_root.path().to_path_buf()),
]);
let resolved = ctx
.resolve_path(trusted_file.to_str().unwrap())
.expect("trusted path should resolve");
assert!(resolved.ends_with("notes.md"));
let other = tempdir().expect("untrusted tempdir");
let other_file = other.path().join("secret.md");
std::fs::write(&other_file, "x").unwrap();
let err = ctx
.resolve_path(other_file.to_str().unwrap())
.expect_err("untrusted path must error");
assert!(matches!(err, ToolError::PathEscape { .. }));
}
#[test]
#[cfg(unix)]
fn test_tool_context_follow_symlinks_allows_nonexistent_path_under_workspace_symlink() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
let resolved = ctx
.resolve_path("linked/new.txt")
.expect("path under workspace symlink should resolve");
let expected = outside
.join("target")
.canonicalize()
.expect("canonical target")
.join("new.txt");
assert_eq!(resolved, normalize_path(&expected));
}
#[test]
#[cfg(unix)]
fn test_tool_context_default_mode_rejects_nonexistent_path_under_workspace_symlink() {
let tmp = tempdir().expect("tempdir");
let workspace = tmp.path().join("workspace");
let outside = tmp.path().join("outside");
std::fs::create_dir_all(&workspace).expect("mkdir workspace");
std::fs::create_dir_all(outside.join("target")).expect("mkdir outside target");
symlink(outside.join("target"), workspace.join("linked")).expect("symlink");
let ctx = ToolContext::new(workspace);
let err = ctx
.resolve_path("linked/new.txt")
.expect_err("default mode should still reject workspace symlink escapes");
assert!(matches!(err, ToolError::PathEscape { .. }));
}
fn scoped_authority(roots: &[&str], files: &[&str]) -> ToolAuthorityEnvelope {
ToolAuthorityEnvelope {
schema_version: 1,
owner: "fleet-worker-1".to_string(),
authority: ToolMutationAuthority::ScopedWrite,
network_access: None,
writable_roots: roots.iter().map(|value| (*value).to_string()).collect(),
writable_files: files.iter().map(|value| (*value).to_string()).collect(),
coordination_contracts: Vec::new(),
}
.normalized()
.expect("valid test authority")
}
#[test]
fn tool_authority_allows_normal_nonexistent_children_only_inside_scope() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
let context = ToolContext::new(tmp.path().to_path_buf());
let authority = scoped_authority(&["src"], &[]);
assert!(
authority
.permits_mutation_path(&context, "src/new/nested.rs")
.expect("normal nonexistent child")
);
assert!(
!authority
.permits_mutation_path(&context, "docs/outside.md")
.expect("ordinary out-of-scope path")
);
}
#[cfg(unix)]
#[test]
fn tool_authority_rejects_exact_file_symlink_aliases() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("src")).expect("src");
std::fs::create_dir(tmp.path().join("other")).expect("other");
std::fs::write(tmp.path().join("other/target.rs"), "outside scope\n").expect("target");
symlink("../other/target.rs", tmp.path().join("src/alias.rs")).expect("alias");
let context = ToolContext::new(tmp.path().to_path_buf());
let authority = scoped_authority(&[], &["src/alias.rs"]);
let error = authority
.permits_mutation_path(&context, "src/alias.rs")
.expect_err("an exact-file claim must not authorize a symlink target")
.to_string();
assert!(error.contains("must not traverse symlinks"), "{error}");
}
#[cfg(unix)]
#[test]
fn tool_authority_rejects_claimed_root_and_child_symlink_aliases() {
let tmp = tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("real")).expect("real");
symlink("real", tmp.path().join("linked")).expect("linked root");
let context = ToolContext::new(tmp.path().to_path_buf());
let claimed_alias = scoped_authority(&["linked"], &[]);
let claimed_real = scoped_authority(&["real"], &[]);
for (authority, path) in [
(&claimed_alias, "linked/new.rs"),
(&claimed_real, "linked/new.rs"),
] {
let error = authority
.permits_mutation_path(&context, path)
.expect_err("symlinked roots and mutation paths must fail closed")
.to_string();
assert!(error.contains("must not traverse symlinks"), "{error}");
}
}
#[test]
fn nested_tool_authority_may_only_narrow_the_outer_cap() {
let tmp = tempdir().expect("tempdir");
let outer = scoped_authority(&["src"], &["Cargo.toml"]);
let narrower = scoped_authority(&["src/parser"], &[]);
let expansion = scoped_authority(&["docs"], &[]);
ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer.clone())
.unwrap()
.with_tool_authority(narrower)
.expect("nested scope may narrow");
let error = ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer.clone())
.unwrap()
.with_tool_authority(expansion)
.err()
.expect("nested scope expansion must fail closed");
assert!(error.contains("cannot expand"), "{error}");
let read_only = ToolAuthorityEnvelope {
schema_version: 1,
owner: "read-only-child".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
};
ToolContext::new(tmp.path().to_path_buf())
.with_tool_authority(outer)
.unwrap()
.with_tool_authority(read_only)
.expect("read-only always narrows a write cap");
}
#[test]
fn process_tool_authority_inherits_into_all_context_constructors() {
const CHILD_ENV: &str = "CODEWHALE_TEST_PROCESS_TOOL_AUTHORITY_CHILD";
if std::env::var_os(CHILD_ENV).is_some() {
let tmp = tempdir().expect("tempdir");
install_process_tool_authority(ToolAuthorityEnvelope {
schema_version: 1,
owner: "fleet-worker-child-process".to_string(),
authority: ToolMutationAuthority::ReadOnly,
network_access: None,
writable_roots: Vec::new(),
writable_files: Vec::new(),
coordination_contracts: Vec::new(),
})
.expect("install process authority once in isolated child");
let notes = tmp.path().join("notes.md");
let mcp = tmp.path().join("mcp.json");
let contexts = [
ToolContext::new(tmp.path().to_path_buf()),
ToolContext::with_options(
tmp.path().to_path_buf(),
false,
notes.clone(),
mcp.clone(),
),
ToolContext::with_auto_approve(tmp.path().to_path_buf(), false, notes, mcp, true),
];
for context in contexts {
let authority = context
.tool_authority
.as_ref()
.expect("every constructor inherits process authority");
assert_eq!(authority.owner, "fleet-worker-child-process");
assert_eq!(authority.authority, ToolMutationAuthority::ReadOnly);
}
return;
}
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.arg("--exact")
.arg(
"tools::spec::tests::process_tool_authority_inherits_into_all_context_constructors",
)
.arg("--nocapture")
.env(CHILD_ENV, "1")
.output()
.expect("spawn isolated authority test child");
assert!(
output.status.success(),
"child failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn test_required_str() {
let input = json!({"name": "test", "count": 42});
assert_eq!(required_str(&input, "name").unwrap(), "test");
assert!(required_str(&input, "missing").is_err());
assert!(required_str(&input, "count").is_err()); }
#[test]
fn test_optional_str() {
let input = json!({"name": "test"});
assert_eq!(optional_str(&input, "name"), Some("test"));
assert_eq!(optional_str(&input, "missing"), None);
}
#[test]
fn test_required_u64() {
let input = json!({"count": 42});
assert_eq!(required_u64(&input, "count").unwrap(), 42);
assert!(required_u64(&input, "missing").is_err());
}
#[test]
fn test_optional_u64() {
let input = json!({"count": 42});
assert_eq!(optional_u64(&input, "count", 0), 42);
assert_eq!(optional_u64(&input, "missing", 100), 100);
}
#[test]
fn test_optional_bool() {
let input = json!({"flag": true});
assert!(optional_bool(&input, "flag", false));
assert!(!optional_bool(&input, "missing", false));
}
#[test]
fn test_tool_error_display() {
let err = ToolError::missing_field("path");
assert_eq!(
format!("{err}"),
"Failed to validate input: missing required field 'path'"
);
let err = ToolError::execution_failed("boom");
assert_eq!(format!("{err}"), "Failed to execute tool: boom");
}
#[test]
fn test_approval_requirement_default() {
let level = ApprovalRequirement::default();
assert_eq!(level, ApprovalRequirement::Auto);
}
}