pub(crate) mod builtins;
pub mod clock;
pub mod context_budget;
pub mod convert;
pub mod image_gen;
pub mod plan_mode;
pub mod question;
mod skill;
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, DEFAULT_WEB_SEARCH_URL, WEB_CACHE_DIR_ENV, WEB_SEARCH_URL_ENV,
};
pub use clock::{CurrentTimeTool, SleepTool, CURRENT_TIME, MAX_SLEEP_SECS, SLEEP};
pub use context_budget::{
ContextBudget, GetContextRemainingTool, NewContextRequest, NewContextTool,
GET_CONTEXT_REMAINING, NEW_CONTEXT,
};
pub use image_gen::{ImageGenTool, IMAGE_GEN};
pub use plan_mode::{
EnterPlanModeTool, ExitPlanModeTool, PlanModeState, ENTER_PLAN_MODE, EXIT_PLAN_MODE,
};
pub use question::{
AskUserTool, Question, QuestionOption, UserQuestionHandler, ASK_USER, REQUEST_USER_INPUT,
};
pub(crate) use builtins::patch_target_paths;
pub(crate) use builtins::build_sandboxed_sh;
#[cfg(test)]
pub(crate) use builtins::bash_view_target;
pub use skill::{SkillTool, SKILL_TOOL};
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>,
}
impl NetworkPolicy {
pub fn domain_rule_set(&self) -> (crate::permissions::RuleSet, crate::permissions::Decision) {
let pattern = |d: &String| format!("domain({d})");
let default = if self.allow_domains.is_empty() {
crate::permissions::Decision::Allow
} else {
crate::permissions::Decision::Deny
};
(
crate::permissions::RuleSet {
deny: self.deny_domains.iter().map(pattern).collect(),
ask: Vec::new(),
allow: self.allow_domains.iter().map(pattern).collect(),
},
default,
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadState {
NeverRead,
Stale,
Fresh,
}
fn content_hash(bytes: &[u8]) -> u64 {
let digest = blake3::hash(bytes);
let b = digest.as_bytes();
u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
}
#[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 extra_roots: Vec<PathBuf>,
pub sandbox: SandboxPolicy,
pub multimodal_read: bool,
pub read_line_numbers: bool,
pub require_read_before_edit: bool,
pub read_paths: Arc<Mutex<HashMap<PathBuf, u64>>>,
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 path_rules: Arc<Vec<crate::path_rules::RuleFile>>,
pub injected_rule_files: Arc<Mutex<HashSet<PathBuf>>>,
pub network_policy: Option<NetworkPolicy>,
pub permission_rules: Option<Arc<crate::permissions::RuleSet>>,
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>,
pub question_handler: Option<question::UserQuestionHandler>,
pub approval_handler: Option<ToolApprovalHandler>,
pub plan_mode: Arc<plan_mode::PlanModeState>,
pub context_budget: Arc<context_budget::ContextBudget>,
pub plan: Arc<Mutex<Vec<crate::session_journal::PlanEntry>>>,
}
#[derive(Clone)]
pub struct ToolApprovalHandler(pub Arc<dyn crate::permissions::PermissionsApprovalHandler>);
impl std::fmt::Debug for ToolApprovalHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ToolApprovalHandler(..)")
}
}
impl std::ops::Deref for ToolApprovalHandler {
type Target = dyn crate::permissions::PermissionsApprovalHandler;
fn deref(&self) -> &Self::Target {
&*self.0
}
}
impl ToolContext {
pub fn new(cwd: impl Into<PathBuf>) -> Self {
ToolContext {
cwd: cwd.into(),
extra_roots: Vec::new(),
sandbox: SandboxPolicy::DangerFullAccess,
multimodal_read: false,
read_line_numbers: false,
require_read_before_edit: false,
read_paths: Arc::new(Mutex::new(HashMap::new())),
notebook_aware: false,
shell_env: None,
nested_instructions: false,
injected_instruction_dirs: Arc::new(Mutex::new(HashSet::new())),
path_rules: Arc::new(Vec::new()),
injected_rule_files: Arc::new(Mutex::new(HashSet::new())),
network_policy: None,
permission_rules: 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,
question_handler: None,
approval_handler: None,
plan_mode: Arc::new(plan_mode::PlanModeState::new()),
context_budget: Arc::new(context_budget::ContextBudget::new()),
plan: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn plan_snapshot(&self) -> Vec<crate::session_journal::PlanEntry> {
self.plan.lock().map(|p| p.clone()).unwrap_or_default()
}
pub fn set_plan(&self, steps: Vec<crate::session_journal::PlanEntry>) {
if let Ok(mut p) = self.plan.lock() {
*p = steps;
}
}
pub fn permissions_engine_active(&self) -> bool {
self.permission_rules.is_some()
}
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 bytes = std::fs::read(path).unwrap_or_default();
self.mark_read_bytes(path, &bytes);
}
pub fn mark_read_bytes(&self, path: &Path, bytes: &[u8]) {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if let Ok(mut map) = self.read_paths.lock() {
map.insert(key, content_hash(bytes));
}
}
pub fn was_read(&self, path: &Path) -> bool {
!matches!(self.read_state(path), ReadState::NeverRead)
}
pub fn read_state(&self, path: &Path) -> ReadState {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let Some(recorded) = self
.read_paths
.lock()
.ok()
.and_then(|map| map.get(&key).copied())
else {
return ReadState::NeverRead;
};
match std::fs::read(&key) {
Ok(bytes) if content_hash(&bytes) == recorded => ReadState::Fresh,
_ => ReadState::Stale,
}
}
pub fn check_network(&self, url: &str) -> Result<()> {
check_network_policy(
self.network_policy.as_ref(),
self.permission_rules.as_deref(),
self.approval_handler.as_deref(),
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 write_roots(&self) -> Vec<PathBuf> {
let mut roots = Vec::with_capacity(1 + self.extra_roots.len());
roots.push(self.cwd.clone());
roots.extend(self.extra_roots.iter().cloned());
roots
}
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 self.write_roots().iter().any(|r| path_within(r, path)) {
Ok(())
} else {
Err(crate::error::Error::tool(
"sandbox",
format!(
"write denied: {} is outside the workspace {} (and its {} \
additional root(s))",
path.display(),
self.cwd.display(),
self.extra_roots.len()
),
))
}
}
}
}
}
pub(crate) fn check_network_policy(
policy: Option<&NetworkPolicy>,
rules: Option<&crate::permissions::RuleSet>,
approval: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
url: &str,
) -> Result<()> {
let ctx_like = domain_tier_of(policy, rules);
check_host_against_tier(&ctx_like, approval, url_host(url).as_deref())
}
pub(crate) fn domain_tier_of(
policy: Option<&NetworkPolicy>,
rules: Option<&crate::permissions::RuleSet>,
) -> (crate::permissions::RuleSet, crate::permissions::Decision) {
let mut out = crate::permissions::RuleSet::default();
let mut default = crate::permissions::Decision::Allow;
if let Some(policy) = policy {
if policy.enabled {
let (list_rules, list_default) = policy.domain_rule_set();
out.deny.extend(list_rules.deny);
out.allow.extend(list_rules.allow);
default = list_default;
}
}
if let Some(config_rules) = rules {
out.deny.extend(config_rules.deny.iter().cloned());
out.ask.extend(config_rules.ask.iter().cloned());
out.allow.extend(config_rules.allow.iter().cloned());
}
(out, default)
}
fn check_host_against_tier(
tier: &(crate::permissions::RuleSet, crate::permissions::Decision),
approval: Option<&dyn crate::permissions::PermissionsApprovalHandler>,
host: Option<&str>,
) -> Result<()> {
use crate::permissions::{Decision, RuleSet};
let (rules, default): (&RuleSet, Decision) = (&tier.0, tier.1);
if rules.is_empty() && default == Decision::Allow {
return Ok(());
}
let Some(host_str) = host else {
return Err(crate::error::Error::tool(
"network",
"cannot determine host from url; denied under an active network policy",
));
};
let host = host_str.to_ascii_lowercase();
match crate::permissions::evaluate_domain(rules, Some(&host), default) {
Decision::Allow => Ok(()),
Decision::Ask => {
let raw_args = serde_json::json!({ "host": host });
let req = crate::permissions::ApprovalRequest {
tool: "domain",
subject: Some(&host),
raw_args: &raw_args,
};
match approval.map(|h| h.ask(&req)) {
Some(crate::permissions::ApprovalOutcome::Allow)
| Some(crate::permissions::ApprovalOutcome::AllowForSession) => Ok(()),
_ => Err(crate::error::Error::tool(
"network",
format!("host `{host}` requires approval and none was given"),
)),
}
}
Decision::Deny => {
if crate::permissions::domain_denied_explicitly(rules, &host) {
Err(crate::error::Error::tool(
"network",
format!("host `{host}` is denied by the active network policy"),
))
} else {
Err(crate::error::Error::tool(
"network",
format!("host `{host}` is not on the network policy's allowlist"),
))
}
}
}
}
pub(crate) fn network_checked_redirect_policy(
policy: Option<NetworkPolicy>,
rules: Option<Arc<crate::permissions::RuleSet>>,
) -> reqwest::redirect::Policy {
const MAX_REDIRECTS: usize = 10; let tier = domain_tier_of(policy.as_ref(), rules.as_deref());
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("too many redirects");
}
if let Err(e) = check_host_against_tier(&tier, None, 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;
fn structured_output(&self) -> bool {
false
}
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 act = &config.module_activation;
let bits = if act.is_active(ModuleId::ToolsApplyPatch) && act.tools_apply_patch_per_model {
config.model_routing.rules_for(&config.model)
} else {
crate::model_catalog::ModelRules::default()
};
let core_has = |name: &str| match (name, bits.apply_patch) {
("edit_file" | "write_file", Some(false)) => true,
("edit_file" | "write_file", Some(true)) => false,
_ => config.core_tools_enabled.iter().any(|t| t == name),
};
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) && bits.search_tool != Some(false) {
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) && bits.apply_patch != Some(false) {
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);
}
}
if act.is_active(ModuleId::ToolsQuestion) {
r.register(AskUserTool::new(question::ASK_USER));
}
if core_has(question::REQUEST_USER_INPUT) {
r.register(AskUserTool::new(question::REQUEST_USER_INPUT));
}
if act.is_active(ModuleId::PlanMode) {
r.register(EnterPlanModeTool);
r.register(ExitPlanModeTool);
}
if core_has(clock::CURRENT_TIME) {
r.register(CurrentTimeTool);
}
if core_has(clock::SLEEP) {
r.register(SleepTool);
}
if core_has(context_budget::GET_CONTEXT_REMAINING) {
r.register(GetContextRemainingTool);
}
if core_has(context_budget::NEW_CONTEXT) {
r.register(NewContextTool);
}
if core_has(image_gen::IMAGE_GEN) {
r.register(ImageGenTool::new(
config.base_url.clone(),
config.api_key.clone(),
config.api_key_env.clone(),
));
}
if config.skills_enabled && config.skills_harness.is_some() {
r.register(
SkillTool::new(crate::skills::load_for_config(config))
.with_shell(crate::skills::ShellInjection::from_config(config)),
);
}
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,
));
}
}