use crate::api::{host_of_url, is_local_ollama_url, resolve_ollama_url};
pub const OFFLINE_ENV: &str = "CLAUDETTE_OFFLINE";
pub const BLOCK_PREFIX: &str = "blocked by offline mode (--offline / CLAUDETTE_OFFLINE)";
pub const NET_TOOLS: &[&str] = &[
"web_search",
"web_fetch",
"gh_inbox",
"gh_get_issue",
"gh_create_issue",
"gh_comment_issue",
"gh_search_code",
"gh_list_repo_issues",
"gh_pr_status",
"gh_pr_view",
"gh_workflow_logs",
"gh_fork",
"gh_create_pr",
"gmail_list",
"gmail_search",
"gmail_read",
"gmail_list_labels",
"calendar_list_events",
"calendar_create_event",
"calendar_update_event",
"calendar_delete_event",
"tv_get_quote",
"wikipedia",
"weather",
"tg_send",
"git_push",
"git_clone",
"mission_start",
"mission_submit",
];
#[must_use]
pub fn is_offline() -> bool {
std::env::var(OFFLINE_ENV)
.ok()
.is_some_and(|v| !v.is_empty() && v != "0")
}
#[must_use]
pub fn allow_list() -> Vec<String> {
let mut hosts = vec![
"localhost".to_string(),
"127.0.0.0/8".to_string(),
"::1".to_string(),
];
let backend = host_of_url(&resolve_ollama_url());
if !backend.is_empty() && !is_loopback_host(&backend) {
hosts.push(backend);
}
hosts
}
#[must_use]
fn is_loopback_host(host: &str) -> bool {
is_local_ollama_url(host)
}
#[must_use]
pub fn is_allowed_host(url: &str) -> bool {
if is_local_ollama_url(url) {
return true;
}
let host = host_of_url(url);
if host.is_empty() {
return false;
}
let backend = host_of_url(&resolve_ollama_url());
!backend.is_empty() && host.eq_ignore_ascii_case(&backend)
}
pub fn guard(url: &str) -> Result<(), String> {
if !is_offline() || is_allowed_host(url) {
return Ok(());
}
let host = host_of_url(url);
let host = if host.is_empty() { url } else { &host };
Err(format!(
"{BLOCK_PREFIX}: outbound connection to '{host}' is not allowed. Only the local \
model backend ({}) and loopback are reachable. Disable offline mode to use this.",
resolve_ollama_url()
))
}
pub fn guard_subprocess(action: &str) -> Result<(), String> {
if !is_offline() {
return Ok(());
}
Err(format!(
"{BLOCK_PREFIX}: {action} requires network access, which is disabled. Only the local \
model backend ({}) and loopback are reachable. Disable offline mode to use this.",
resolve_ollama_url()
))
}
#[cfg(test)]
mod tests {
use super::*;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct EnvGuard {
offline: Option<String>,
ollama: Option<String>,
}
impl EnvGuard {
fn capture() -> Self {
Self {
offline: std::env::var(OFFLINE_ENV).ok(),
ollama: std::env::var("OLLAMA_HOST").ok(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
restore(OFFLINE_ENV, self.offline.as_deref());
restore("OLLAMA_HOST", self.ollama.as_deref());
}
}
fn restore(key: &str, val: Option<&str>) {
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn is_offline_reads_truthy_env() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::remove_var(OFFLINE_ENV);
assert!(!is_offline(), "unset → off");
std::env::set_var(OFFLINE_ENV, "");
assert!(!is_offline(), "empty → off");
std::env::set_var(OFFLINE_ENV, "0");
assert!(!is_offline(), "literal 0 → off");
std::env::set_var(OFFLINE_ENV, "1");
assert!(is_offline(), "1 → on");
std::env::set_var(OFFLINE_ENV, "true");
assert!(is_offline(), "any other non-empty → on");
}
#[test]
fn loopback_hosts_always_allowed() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::set_var("OLLAMA_HOST", "http://localhost:11434");
for url in [
"http://localhost:11434/api/chat",
"http://127.0.0.1:1234/v1/chat/completions",
"http://127.255.255.255:11434",
"https://[::1]:443/x",
"localhost:11434",
] {
assert!(is_allowed_host(url), "{url} should be allowed (loopback)");
}
}
#[test]
fn cloud_hosts_denied() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::set_var("OLLAMA_HOST", "http://localhost:11434");
for url in [
"https://api.github.com/user",
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
"https://oauth2.googleapis.com/token",
"https://api.search.brave.com/res/v1/web/search",
"https://en.wikipedia.org/w/api.php",
"https://api.open-meteo.com/v1/forecast",
"https://scanner.tradingview.com/america/scan",
"https://api.telegram.org/bot123/getUpdates",
"https://localhost.evil.com/x",
"http://user:pass@evil.com/path",
] {
assert!(!is_allowed_host(url), "{url} should be denied (cloud)");
}
}
#[test]
fn configured_lan_backend_allowed_other_cloud_still_denied() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::set_var("OLLAMA_HOST", "http://192.168.1.50:11434");
assert!(
is_allowed_host("http://192.168.1.50:11434/api/chat"),
"the configured backend host is allowed"
);
assert!(
is_allowed_host("http://192.168.1.50:8080/anything"),
"other ports on the backend box are allowed (host-level match)"
);
assert!(
!is_allowed_host("http://192.168.1.99:11434/api/chat"),
"a different LAN host is not the backend"
);
assert!(!is_allowed_host("https://api.github.com/user"));
}
#[test]
fn guard_is_noop_when_offline_off() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::remove_var(OFFLINE_ENV);
assert!(guard("https://api.github.com/user").is_ok());
assert!(guard_subprocess("git_push").is_ok());
}
#[test]
fn guard_blocks_cloud_and_allows_backend_when_offline_on() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::set_var("OLLAMA_HOST", "http://localhost:11434");
std::env::set_var(OFFLINE_ENV, "1");
assert!(guard("http://localhost:11434/api/embeddings").is_ok());
let err = guard("https://api.github.com/user").unwrap_err();
assert!(
err.starts_with(BLOCK_PREFIX),
"uses the shared prefix: {err}"
);
assert!(
err.contains("api.github.com"),
"names the blocked host: {err}"
);
let sub = guard_subprocess("git_clone (clone a remote repository)").unwrap_err();
assert!(
sub.starts_with(BLOCK_PREFIX),
"subprocess shares the prefix: {sub}"
);
assert!(sub.contains("git_clone"), "names the action: {sub}");
}
#[test]
fn allow_list_includes_loopback_and_lan_backend() {
let _lock = ENV_LOCK.lock().unwrap();
let _g = EnvGuard::capture();
std::env::set_var("OLLAMA_HOST", "http://localhost:11434");
let local = allow_list();
assert!(local.iter().any(|h| h == "localhost"));
assert!(local.iter().any(|h| h == "127.0.0.0/8"));
assert!(!local.iter().any(|h| h == "11434"));
std::env::set_var("OLLAMA_HOST", "http://192.168.1.50:11434");
let lan = allow_list();
assert!(
lan.iter().any(|h| h == "192.168.1.50"),
"LAN backend host listed: {lan:?}"
);
}
}