mod builtins;
pub(crate) mod tiers;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::config::Config;
use crate::error::Result;
use crate::modules::ModuleId;
pub use builtins::{
ApplyPatchTool, BashTool, EditFileTool, GlobTool, ListDirTool, PersistentShellTool,
ReadFileTool, SearchTool, UpdatePlanTool, ViewImageTool, WebFetchTool, WebSearchTool,
WriteFileTool, WEB_SEARCH_URL_ENV,
};
pub(crate) use builtins::patch_target_paths;
pub(crate) use builtins::build_sandboxed_sh;
pub use tiers::{minify as minify_tool_schema, SchemaTier};
pub const MULTIMODAL_IMAGE_MARKER: &str = "\u{1}SUPERCODE_IMAGE_DATA_URL\u{1}";
pub const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
pub fn is_image_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| IMAGE_EXTENSIONS.contains(&e.to_ascii_lowercase().as_str()))
.unwrap_or(false)
}
pub fn image_mime_for(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
_ => "image/png",
}
}
pub const NOTEBOOK_EXTENSION: &str = "ipynb";
#[derive(Debug, Clone, Default)]
pub struct NetworkPolicy {
pub enabled: bool,
pub allow_domains: Vec<String>,
pub deny_domains: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SandboxPolicy {
ReadOnly,
WorkspaceWrite,
#[default]
DangerFullAccess,
}
#[async_trait]
pub trait WriteObserver: Send + Sync + std::fmt::Debug {
async fn before_write(&self, path: &Path);
async fn after_write(&self, path: &Path) -> Option<String>;
}
#[derive(Debug)]
pub struct WriteObserverChain(Vec<Arc<dyn WriteObserver>>);
impl WriteObserverChain {
pub fn new(observers: Vec<Arc<dyn WriteObserver>>) -> Self {
WriteObserverChain(observers)
}
}
#[async_trait]
impl WriteObserver for WriteObserverChain {
async fn before_write(&self, path: &Path) {
for obs in &self.0 {
obs.before_write(path).await;
}
}
async fn after_write(&self, path: &Path) -> Option<String> {
let mut notes: Vec<String> = Vec::new();
for obs in &self.0 {
if let Some(note) = obs.after_write(path).await {
if !note.is_empty() {
notes.push(note);
}
}
}
if notes.is_empty() {
None
} else {
Some(notes.join("\n\n"))
}
}
}
#[derive(Debug, Clone)]
pub struct ToolContext {
pub cwd: PathBuf,
pub sandbox: SandboxPolicy,
pub multimodal_read: bool,
pub require_read_before_edit: bool,
pub read_paths: Arc<Mutex<HashSet<PathBuf>>>,
pub notebook_aware: bool,
pub shell_env: Option<Arc<HashMap<String, String>>>,
pub nested_instructions: bool,
pub injected_instruction_dirs: Arc<Mutex<HashSet<PathBuf>>>,
pub network_policy: Option<NetworkPolicy>,
pub bash_timeout_secs: Option<u64>,
pub write_observer: Option<Arc<dyn WriteObserver>>,
pub sandbox_os_enabled: Option<bool>,
pub sandbox_escalation: crate::sandbox::SandboxEscalation,
pub sandbox_env_policy: crate::sandbox::SandboxEnvPolicy,
pub sandbox_approval_handler: Option<crate::sandbox::SandboxApprovalHandler>,
}
impl ToolContext {
pub fn new(cwd: impl Into<PathBuf>) -> Self {
ToolContext {
cwd: cwd.into(),
sandbox: SandboxPolicy::DangerFullAccess,
multimodal_read: false,
require_read_before_edit: false,
read_paths: Arc::new(Mutex::new(HashSet::new())),
notebook_aware: false,
shell_env: None,
nested_instructions: false,
injected_instruction_dirs: Arc::new(Mutex::new(HashSet::new())),
network_policy: None,
bash_timeout_secs: None,
write_observer: None,
sandbox_os_enabled: None,
sandbox_escalation: crate::sandbox::SandboxEscalation::default(),
sandbox_env_policy: crate::sandbox::SandboxEnvPolicy::default(),
sandbox_approval_handler: None,
}
}
pub fn os_sandbox_active(&self) -> bool {
crate::sandbox::os_sandbox_active(self.sandbox, self.sandbox_os_enabled)
}
pub fn mark_read(&self, path: &Path) {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if let Ok(mut set) = self.read_paths.lock() {
set.insert(key);
}
}
pub fn was_read(&self, path: &Path) -> bool {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
self.read_paths
.lock()
.map(|set| set.contains(&key))
.unwrap_or(false)
}
pub fn check_network(&self, url: &str) -> Result<()> {
check_network_policy(self.network_policy.as_ref(), url)
}
pub fn resolve(&self, path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.is_absolute() {
p
} else {
self.cwd.join(p)
}
}
pub fn check_write(&self, path: &Path) -> Result<()> {
match self.sandbox {
SandboxPolicy::DangerFullAccess => Ok(()),
SandboxPolicy::ReadOnly => Err(crate::error::Error::tool(
"sandbox",
"write denied: sandbox is read-only",
)),
SandboxPolicy::WorkspaceWrite => {
if path_within(&self.cwd, path) {
Ok(())
} else {
Err(crate::error::Error::tool(
"sandbox",
format!(
"write denied: {} is outside the workspace {}",
path.display(),
self.cwd.display()
),
))
}
}
}
}
}
pub(crate) fn check_network_policy(policy: Option<&NetworkPolicy>, url: &str) -> Result<()> {
let Some(policy) = policy else {
return Ok(());
};
if !policy.enabled {
return Ok(());
}
check_host_against_policy(policy, url_host(url).as_deref())
}
fn check_host_against_policy(policy: &NetworkPolicy, host: Option<&str>) -> Result<()> {
let Some(host) = host else {
return Err(crate::error::Error::tool(
"network",
"cannot determine host from url; denied under an active network policy",
));
};
let host = host.to_ascii_lowercase();
if policy.deny_domains.iter().any(|d| d == &host) {
return Err(crate::error::Error::tool(
"network",
format!("host `{host}` is denied by the active network policy"),
));
}
if !policy.allow_domains.is_empty() && !policy.allow_domains.iter().any(|d| d == &host) {
return Err(crate::error::Error::tool(
"network",
format!("host `{host}` is not on the network policy's allowlist"),
));
}
Ok(())
}
pub(crate) fn network_checked_redirect_policy(
policy: Option<NetworkPolicy>,
) -> reqwest::redirect::Policy {
const MAX_REDIRECTS: usize = 10; reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
if let Some(policy) = &policy {
if policy.enabled {
if let Err(e) = check_host_against_policy(policy, attempt.url().host_str()) {
return attempt.error(e.to_string());
}
}
}
attempt.follow()
})
}
fn url_host(url: &str) -> Option<String> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let authority = &rest[..end];
let host_and_port = authority.rsplit('@').next().unwrap_or(authority);
let host = host_and_port.split(':').next().unwrap_or(host_and_port);
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
pub fn shell_sandbox_unenforceable(
policy: SandboxPolicy,
platform: &str,
tools_enabled: &[&str],
landlock_available: bool,
) -> bool {
policy != SandboxPolicy::DangerFullAccess
&& platform != "macos"
&& !(platform == "linux" && landlock_available)
&& tools_enabled.iter().any(|t| *t == "bash" || *t == "shell")
}
fn path_within(root: &Path, path: &Path) -> bool {
crate::safe_path::contained(root, path)
}
pub(crate) fn normalize(path: &Path) -> Option<PathBuf> {
use std::path::Component;
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
let mut out = PathBuf::new();
for c in abs.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
Some(out)
}
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value;
async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<String>;
}
#[derive(Default)]
pub struct ToolRegistry {
tools: Vec<Box<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
ToolRegistry::default()
}
pub fn with_builtins() -> Self {
let mut r = ToolRegistry::new();
r.register(ReadFileTool);
r.register(WriteFileTool);
r.register(EditFileTool);
r.register(ListDirTool);
r.register(GlobTool);
r.register(SearchTool);
r.register(ApplyPatchTool);
r.register(BashTool::default());
r.register(PersistentShellTool::default());
r.register(UpdatePlanTool::default());
r
}
pub fn from_config(config: &Config) -> Self {
if !config.module_registry {
return Self::with_builtins();
}
let mut r = ToolRegistry::new();
let core_has = |name: &str| config.core_tools_enabled.iter().any(|t| t == name);
let act = &config.module_activation;
if core_has("read_file") {
r.register(ReadFileTool);
}
if core_has("write_file") {
r.register(WriteFileTool);
}
if core_has("edit_file") {
r.register(EditFileTool);
}
if core_has("view_image") {
r.register(ViewImageTool);
}
if act.is_active(ModuleId::ToolsSearch) {
if act.tools_search_list_dir {
r.register(ListDirTool);
}
if act.tools_search_glob {
r.register(GlobTool);
}
if act.tools_search_content_search {
r.register(SearchTool);
}
}
if act.is_active(ModuleId::ToolsApplyPatch) {
r.register(ApplyPatchTool);
}
if core_has("bash") {
r.register(BashTool::default());
}
if act.is_active(ModuleId::ToolsPersistentShell) {
r.register(PersistentShellTool::default());
}
if act.is_active(ModuleId::Todos) {
r.register(UpdatePlanTool::default());
}
if act.is_active(ModuleId::ToolsWeb) {
if act.tools_web_fetch {
r.register(WebFetchTool);
}
if act.tools_web_search {
r.register(WebSearchTool);
}
}
r
}
pub fn register(&mut self, tool: impl Tool + 'static) {
self.tools.push(Box::new(tool));
}
pub fn get(&self, name: &str) -> Option<&dyn Tool> {
self.tools
.iter()
.rev()
.find(|t| t.name() == name)
.map(|b| b.as_ref())
}
pub fn iter(&self) -> impl Iterator<Item = &dyn Tool> {
self.tools.iter().map(|b| b.as_ref())
}
pub fn len(&self) -> usize {
self.tools.len()
}
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_sandbox_unenforceable_truth_table() {
assert!(shell_sandbox_unenforceable(
SandboxPolicy::ReadOnly,
"linux",
&["bash"],
false,
));
assert!(shell_sandbox_unenforceable(
SandboxPolicy::WorkspaceWrite,
"linux",
&["shell"],
false,
));
assert!(shell_sandbox_unenforceable(
SandboxPolicy::WorkspaceWrite,
"windows",
&["bash", "shell"],
true,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::ReadOnly,
"linux",
&["bash"],
true,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::WorkspaceWrite,
"linux",
&["shell"],
true,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::DangerFullAccess,
"linux",
&["bash", "shell"],
false,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::ReadOnly,
"macos",
&["bash", "shell"],
true,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::ReadOnly,
"linux",
&[],
false,
));
assert!(!shell_sandbox_unenforceable(
SandboxPolicy::ReadOnly,
"linux",
&["write_file"],
false,
));
}
}