Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion crates/api/src/anthropic/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,19 @@ pub struct AnthropicClient {
http: reqwest::Client,
base_url: String,
auth: Box<dyn crab_auth::AuthProvider>,
anti_track: Option<crab_config::AntiTrackConfig>,
}

impl AnthropicClient {
pub fn new(base_url: &str, auth: Box<dyn crab_auth::AuthProvider>) -> Self {
Self::new_with_anti_track(base_url, auth, None)
}

pub fn new_with_anti_track(
base_url: &str,
auth: Box<dyn crab_auth::AuthProvider>,
anti_track: Option<crab_config::AntiTrackConfig>,
) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(4)
Expand All @@ -30,6 +39,7 @@ impl AnthropicClient {
http,
base_url: base_url.to_string(),
auth,
anti_track,
}
}

Expand Down Expand Up @@ -57,6 +67,21 @@ impl AnthropicClient {
Ok(builder)
}

/// Apply anti-track sanitization to a request before sending.
fn apply_anti_track(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref config) = self.anti_track {
match builder.build() {
Ok(mut request) => {
crate::anti_track::sanitize_request(&mut request, config);
self.http.request(request.method().clone(), request.url().clone()).headers(request.headers().clone()).body(request.body().cloned().unwrap_or_default())
}
Err(_) => builder,
}
} else {
builder
}
}

/// Streaming call — POST `/v1/messages` with `stream: true`.
///
/// Returns a stream of `StreamEvent` mapped from Anthropic SSE events.
Expand All @@ -70,6 +95,7 @@ impl AnthropicClient {
futures::stream::once(async move {
let body = serde_json::to_vec(&api_req).map_err(ApiError::Json)?;
let request = self.build_request(&body).await?;
let request = self.apply_anti_track(request);
let response = request.send().await.map_err(ApiError::Http)?;

let status = response.status();
Expand Down Expand Up @@ -115,6 +141,7 @@ impl AnthropicClient {
let api_req = convert::to_anthropic_request(&req, false);
let body = serde_json::to_vec(&api_req).map_err(ApiError::Json)?;
let request = self.build_request(&body).await?;
let request = self.apply_anti_track(request);
let response = request.send().await.map_err(ApiError::Http)?;

let status = response.status();
Expand Down Expand Up @@ -162,6 +189,7 @@ impl AnthropicClient {
}
}

let builder = self.apply_anti_track(builder);
let response = builder.send().await.map_err(ApiError::Http)?;
let status = response.status();
if !status.is_success() {
Expand Down Expand Up @@ -197,7 +225,7 @@ impl AnthropicClient {
Ok(models)
}

/// Health check — verify the API is reachable and the key is valid.
/// Health check — verify the API is reachable and the API key is valid.
///
/// Sends a minimal request to validate connectivity and authentication.
pub async fn health_check(&self) -> crate::capabilities::HealthStatus {
Expand Down Expand Up @@ -232,6 +260,7 @@ impl AnthropicClient {
}
}

let builder = self.apply_anti_track(builder);
match builder.send().await {
Ok(resp) => {
let status = resp.status();
Expand Down
139 changes: 139 additions & 0 deletions crates/api/src/anti_track.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Anti-Tracking and Anti-Upgrade Module
//!
//! Provides mechanisms to:
//! - Disable automatic version checks and updates
//! - Remove/block telemetry and analytics requests
//! - Sanitize network requests to avoid detection
//!
//! This module is inspired by the anti-track implementation in
//! [rusty-ai-cli](https://github.com/lorryjovens-hub/claude-code-rust),
//! contributed with assistance from the Hermes Agent.

use std::sync::LazyLock;

pub use crab_config::AntiTrackConfig;

/// Domains known to be used for tracking/telemetry.
pub static TRACKING_DOMAINS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"telemetry.anthropic.com",
"analytics.anthropic.com",
"client.telemetry.github.com",
"api.segment.io",
"events.devoptix.com",
"litestream.io",
"ipfs.io",
"cloudflare-ipfs.com",
]
});

/// Sensitive header names that reveal identity or enable tracking.
pub static SENSITIVE_HEADERS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
vec![
"X-Claude-Client-Name",
"X-Claude-Client-Version",
"X-Anthropic-Telemetry",
"X-Analytics",
"X-Tracking-ID",
"X-Session-ID",
"X-User-ID",
]
});

/// Check if a URL should be blocked based on anti-track rules.
#[must_use]
pub fn should_block_url(url: &str, config: &AntiTrackConfig) -> bool {
if config.block_tracking_domains && TRACKING_DOMAINS.iter().any(|d| url.contains(d)) {
return true;
}

if config.disable_version_check && (url.contains("/version") || url.contains("/update") || url.contains("/check")) {
return true;
}

false
}

/// Sanitize a `reqwest::Request` by removing tracking headers.
pub fn sanitize_request(request: &mut reqwest::Request, config: &AntiTrackConfig) {
let headers = request.headers_mut();

// Remove sensitive headers.
for name in SENSITIVE_HEADERS.iter() {
if let Ok(h) = name.parse::<reqwest::header::HeaderName>() {
headers.remove(h);
}
}

// Remove any header containing telemetry/analytics/tracking keywords.
let to_remove: Vec<reqwest::header::HeaderName> = headers
.keys()
.filter(|k| {
let lower = k.as_str().to_lowercase();
lower.contains("telemetry")
|| lower.contains("analytics")
|| lower.contains("tracking")
|| lower.contains("crash")
|| lower.contains("feedback")
})
.cloned()
.collect();

for key in to_remove {
headers.remove(key);
}

// Spoof User-Agent if enabled.
if config.spoof_version {
if let Ok(ua) = reqwest::header::HeaderValue::from_str(&format!(
"CrabCode/{} (Rust CLI)",
config.spoofed_version
)) {
headers.insert(reqwest::header::USER_AGENT, ua);
}
}
}

/// Convenience wrapper that returns `None` when the request should be blocked.
pub fn apply(request: reqwest::Request, config: &AntiTrackConfig) -> Option<reqwest::Request> {
if should_block_url(request.url().as_str(), config) {
return None;
}
let mut request = request;
sanitize_request(&mut request, config);
Some(request)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_block_tracking_domain() {
let config = AntiTrackConfig {
block_tracking_domains: true,
..Default::default()
};
assert!(should_block_url("https://telemetry.anthropic.com/v1/track", &config));
assert!(!should_block_url("https://api.anthropic.com/v1/messages", &config));
}

#[test]
fn test_sanitize_request() {
let config = AntiTrackConfig {
spoof_version: false,
..Default::default()
};
let mut request = reqwest::Request::new(
reqwest::Method::GET,
"https://api.anthropic.com/v1/models".parse().unwrap(),
);
request.headers_mut().insert("X-Claude-Client-Version", "1.0.0".parse().unwrap());
request.headers_mut().insert("Authorization", "Bearer token".parse().unwrap());

sanitize_request(&mut request, &config);

assert!(!request.headers().contains_key("X-Claude-Client-Version"));
assert!(request.headers().contains_key("Authorization"));
}
}
14 changes: 10 additions & 4 deletions crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! No dynamic trait dispatch; compile-time determined, exhaustive match.

pub mod anthropic;
pub mod anti_track;
pub mod cache;
pub mod capabilities;
pub mod context_optimizer;
Expand Down Expand Up @@ -136,6 +137,9 @@ impl LlmBackend {
/// - Everything else (including `None`) → `AnthropicClient`
#[must_use]
pub fn create_backend(settings: &crab_config::Settings) -> LlmBackend {
// Extract anti-track config from settings
let anti_track = settings.anti_track.clone();

match settings.api_provider.as_deref() {
Some("openai" | "ollama" | "deepseek" | "vllm") => {
let base_url = settings
Expand All @@ -146,7 +150,7 @@ pub fn create_backend(settings: &crab_config::Settings) -> LlmBackend {
.api_key
.clone()
.or_else(|| std::env::var("OPENAI_API_KEY").ok());
LlmBackend::OpenAi(openai::OpenAiClient::new(base_url, api_key))
LlmBackend::OpenAi(openai::OpenAiClient::new_with_anti_track(base_url, api_key, anti_track))
}
#[cfg(feature = "bedrock")]
Some("bedrock") => {
Expand All @@ -169,9 +173,10 @@ pub fn create_backend(settings: &crab_config::Settings) -> LlmBackend {
|_| {
// Fall back to direct Anthropic if Bedrock auth fails
let auth = crab_auth::create_auth_provider(settings);
LlmBackend::Anthropic(anthropic::AnthropicClient::new(
LlmBackend::Anthropic(anthropic::AnthropicClient::new_with_anti_track(
"https://api.anthropic.com",
auth,
anti_track,
))
},
LlmBackend::Bedrock,
Expand Down Expand Up @@ -200,9 +205,10 @@ pub fn create_backend(settings: &crab_config::Settings) -> LlmBackend {
vertex::create_vertex_client(&config).map_or_else(
|_| {
let auth = crab_auth::create_auth_provider(settings);
LlmBackend::Anthropic(anthropic::AnthropicClient::new(
LlmBackend::Anthropic(anthropic::AnthropicClient::new_with_anti_track(
"https://api.anthropic.com",
auth,
anti_track,
))
},
LlmBackend::Vertex,
Expand All @@ -214,7 +220,7 @@ pub fn create_backend(settings: &crab_config::Settings) -> LlmBackend {
.as_deref()
.unwrap_or("https://api.anthropic.com");
let auth = crab_auth::create_auth_provider(settings);
LlmBackend::Anthropic(anthropic::AnthropicClient::new(base_url, auth))
LlmBackend::Anthropic(anthropic::AnthropicClient::new_with_anti_track(base_url, auth, anti_track))
}
}
}
31 changes: 29 additions & 2 deletions crates/api/src/openai/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ pub struct OpenAiClient {
http: reqwest::Client,
base_url: String,
api_key: Option<String>,
anti_track: Option<crab_config::AntiTrackConfig>,
}

impl OpenAiClient {
pub fn new(base_url: &str, api_key: Option<String>) -> Self {
Self::new_with_anti_track(base_url, api_key, None)
}

pub fn new_with_anti_track(
base_url: &str,
api_key: Option<String>,
anti_track: Option<crab_config::AntiTrackConfig>,
) -> Self {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.pool_max_idle_per_host(4)
Expand All @@ -29,6 +38,22 @@ impl OpenAiClient {
http,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
anti_track,
}
}

/// Apply anti-track sanitization to a request before sending.
fn apply_anti_track(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref config) = self.anti_track {
match builder.build() {
Ok(mut request) => {
crate::anti_track::sanitize_request(&mut request, config);
self.http.request(request.method().clone(), request.url().clone()).headers(request.headers().clone()).body(request.body().cloned().unwrap_or_default())
}
Err(_) => builder,
}
} else {
builder
}
}

Expand Down Expand Up @@ -60,7 +85,7 @@ impl OpenAiClient {
// We need to use stream::once + flatten to handle the async request
// setup followed by the streaming response.
stream::once(async move {
let resp = self.build_request(&chat_req).send().await.map_err(|e| {
let resp = self.apply_anti_track(self.build_request(&chat_req)).send().await.map_err(|e| {
if e.is_timeout() {
ApiError::Timeout
} else {
Expand Down Expand Up @@ -93,7 +118,7 @@ impl OpenAiClient {
pub async fn send(&self, req: MessageRequest<'_>) -> Result<MessageResponse> {
let chat_req = convert::to_chat_completion_request(&req, false);

let resp = self.build_request(&chat_req).send().await.map_err(|e| {
let resp = self.apply_anti_track(self.build_request(&chat_req)).send().await.map_err(|e| {
if e.is_timeout() {
ApiError::Timeout
} else {
Expand Down Expand Up @@ -130,6 +155,7 @@ impl OpenAiClient {
builder = builder.bearer_auth(key);
}

let builder = self.apply_anti_track(builder);
let response = builder.send().await.map_err(|e| {
if e.is_timeout() {
crate::error::ApiError::Timeout
Expand Down Expand Up @@ -184,6 +210,7 @@ impl OpenAiClient {
builder = builder.bearer_auth(key);
}

let builder = self.apply_anti_track(builder);
match builder.send().await {
Ok(resp) => {
let status = resp.status();
Expand Down
Loading