//! Tool Search domain restriction bypass patch for Claude Code. //! //! Resolves the current active `claude` command from PATH and patches the //! domain whitelist check //! `return["api.anthropic.com"].includes(x)}catch{return!1}` //! to always return true via equal-length byte replacement. use regex::bytes::Regex; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; use sha2::{Digest, Sha256}; use crate::error::AppError; const BACKUP_SUFFIX: &str = ".toolsearch-bak"; /// Encode bytes as lowercase hex string (avoids adding `hex` crate dependency). fn to_hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } // Regex matching the domain whitelist check with any JS identifier as variable name const PATCH_TARGET_PATTERN: &str = r#"return\["api\.anthropic\.com"\]\.includes\([A-Za-z_$][A-Za-z0-9_$]*\)\}catch\{return!1\}"#; // Regex matching already-patched code const PATCHED_PATTERN: &str = r#"return!0/\* *\*/\}catch\{return!0\}"#; /// Single Claude Code installation info #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ClaudeInstallation { pub path: String, pub source: String, pub patched: bool, pub has_backup: bool, } /// Result of a patch/restore operation on one installation #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PatchResult { pub path: String, pub success: bool, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } /// Overall Tool Search patch status #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolSearchStatus { pub installations: Vec, pub all_patched: bool, pub any_found: bool, } // ── Patch status detection ────────────────────────────────────────── fn get_patch_status(data: &[u8]) -> &'static str { let target_re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); let patched_re = Regex::new(PATCHED_PATTERN).unwrap(); if target_re.is_match(data) { "unpatched" } else if patched_re.is_match(data) { "patched" } else { "unknown" } } /// Build equal-length replacement bytes: `return!0/* */}catch{return!0}` fn build_patched_bytes(original_len: usize) -> Result, AppError> { let prefix = b"return!0/*"; let suffix = b"*/}catch{return!0}"; let padding = original_len .checked_sub(prefix.len() + suffix.len()) .ok_or_else(|| AppError::Config("Patch template too long for match".into()))?; let mut out = Vec::with_capacity(original_len); out.extend_from_slice(prefix); out.extend(std::iter::repeat_n(b' ', padding)); out.extend_from_slice(suffix); Ok(out) } /// Apply byte-level patch to file data, returns (patched_data, replacement_count) fn patch_bytes(data: &[u8]) -> Result<(Vec, usize), AppError> { let re = Regex::new(PATCH_TARGET_PATTERN).unwrap(); let mut count = 0usize; let result = re.replace_all(data, |caps: ®ex::bytes::Captures| { count += 1; build_patched_bytes(caps[0].len()).unwrap_or_else(|_| caps[0].to_vec()) }); Ok((result.into_owned(), count)) } // ── Installation detection ────────────────────────────────────────── /// Run a command and return stdout, or empty string on failure fn run_cmd(cmd: &str, args: &[&str]) -> String { Command::new(cmd) .args(args) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_default() } /// Search a package directory for JS files containing the domain check fn find_patch_target_in_pkg(pkg_dir: &Path) -> Option { let marker = b"api.anthropic.com"; // Check cli.js first (most common) let cli_js = pkg_dir.join("cli.js"); if cli_js.is_file() { if let Ok(data) = std::fs::read(&cli_js) { if data.windows(marker.len()).any(|w| w == marker) { return Some(cli_js); } } } // Search other JS files find_js_with_marker(pkg_dir) } fn find_js_with_marker(dir: &Path) -> Option { let marker = b"api.anthropic.com"; let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(_) => return None, }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if let Some(found) = find_js_with_marker(&path) { return Some(found); } } else if path.extension().and_then(|e| e.to_str()) == Some("js") { if let Ok(meta) = path.metadata() { if meta.len() < 1000 { continue; } } if let Ok(data) = std::fs::read(&path) { if data.windows(marker.len()).any(|w| w == marker) { return Some(path); } } } } None } /// Resolve symlinks to actual file path fn resolve_target(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } fn get_patch_status_for_path(path: &Path) -> Option<&'static str> { let data = std::fs::read(path).ok()?; Some(get_patch_status(&data)) } fn package_dir_from_ancestors(path: &Path) -> Option { for ancestor in path.ancestors() { if ancestor.file_name().and_then(|v| v.to_str()) == Some("claude-code") && ancestor .parent() .and_then(|v| v.file_name()) .and_then(|v| v.to_str()) == Some("@anthropic-ai") { return Some(ancestor.to_path_buf()); } } None } fn push_candidate_package_dir( candidates: &mut Vec, seen: &mut std::collections::HashSet, path: PathBuf, ) { if path.is_dir() && seen.insert(path.clone()) { candidates.push(path); } } fn resolve_active_patch_target(command_path: &Path) -> Option { let resolved_command = resolve_target(command_path); if matches!( get_patch_status_for_path(&resolved_command), Some("patched" | "unpatched") ) { return Some(resolved_command); } let mut candidates = Vec::new(); let mut seen = std::collections::HashSet::new(); for path in [command_path, resolved_command.as_path()] { if let Some(pkg_dir) = package_dir_from_ancestors(path) { push_candidate_package_dir(&mut candidates, &mut seen, pkg_dir); } if let Some(bin_dir) = path.parent() { if let Some(prefix) = bin_dir.parent() { push_candidate_package_dir( &mut candidates, &mut seen, prefix .join("lib") .join("node_modules") .join("@anthropic-ai") .join("claude-code"), ); push_candidate_package_dir( &mut candidates, &mut seen, prefix .join("node_modules") .join("@anthropic-ai") .join("claude-code"), ); } } } candidates .into_iter() .find_map(|pkg_dir| find_patch_target_in_pkg(&pkg_dir).map(|p| resolve_target(&p))) } #[cfg(target_os = "windows")] fn find_active_command_path() -> Option { run_cmd("where.exe", &["claude"]) .lines() .next() .map(PathBuf::from) .filter(|path| path.is_file()) } #[cfg(not(target_os = "windows"))] fn find_active_command_path() -> Option { run_cmd("which", &["claude"]) .lines() .next() .map(PathBuf::from) .filter(|path| path.is_file()) } fn find_active_installation() -> Option<(PathBuf, String)> { let command_path = find_active_command_path()?; let patch_target = resolve_active_patch_target(&command_path)?; Some(( patch_target, format!("active claude ({})", command_path.display()), )) } fn require_active_installation() -> Result<(PathBuf, String), AppError> { find_active_installation() .ok_or_else(|| AppError::Config("No active Claude Code installation found in PATH".into())) } // ── macOS codesign ────────────────────────────────────────────────── #[cfg(target_os = "macos")] fn codesign_adhoc(path: &Path) -> Result<(), AppError> { let output = Command::new("codesign") .args(["--force", "--sign", "-"]) .arg(path) .output() .map_err(|e| AppError::IoContext { context: format!("Failed to run codesign for {}", path.display()), source: e, })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(AppError::Config(format!( "codesign failed for {}: {}", path.display(), stderr.trim() ))); } Ok(()) } #[cfg(not(target_os = "macos"))] fn codesign_adhoc(_path: &Path) -> Result<(), AppError> { Ok(()) } // ── Backup directory helpers ───────────────────────────────────────── /// Get the centralized backup directory: `~/.cc-switch/toolsearch-backups/` fn get_backup_dir() -> Result { let dir = crate::config::get_app_config_dir().join("toolsearch-backups"); if !dir.exists() { std::fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; } Ok(dir) } /// Derive a stable backup filename from the original path using SHA-256. fn backup_name_for(path: &Path) -> String { let mut hasher = Sha256::new(); hasher.update(path.to_string_lossy().as_bytes()); to_hex(&hasher.finalize()) } /// Get the backup file path for a given target file. fn get_backup_path(path: &Path) -> Result { let dir = get_backup_dir()?; let name = backup_name_for(path); Ok(dir.join(format!("{name}.bak"))) } /// Get the metadata file path (records original path for debugging). fn get_meta_path(path: &Path) -> Result { let dir = get_backup_dir()?; let name = backup_name_for(path); Ok(dir.join(format!("{name}.meta"))) } // ── Patch / Restore single file ───────────────────────────────────── fn patch_single_file(path: &Path) -> Result<(), AppError> { let data = std::fs::read(path).map_err(|e| AppError::io(path, e))?; let status = get_patch_status(&data); if status == "patched" { return Ok(()); // Already patched } if status == "unknown" { return Err(AppError::Config(format!( "Target pattern not found in {}, possibly incompatible version", path.display() ))); } let (patched_data, count) = patch_bytes(&data)?; if count == 0 { return Err(AppError::Config(format!( "No replacements made in {}", path.display() ))); } // Create backup in centralized directory let backup_path = get_backup_path(path)?; std::fs::copy(path, &backup_path).map_err(|e| AppError::io(&backup_path, e))?; // Write metadata file for debugging let meta_path = get_meta_path(path)?; let _ = std::fs::write(&meta_path, path.to_string_lossy().as_bytes()); // Write patched data if let Err(e) = std::fs::write(path, &patched_data) { // Try rename trick on Windows #[cfg(target_os = "windows")] { if let Ok(()) = write_via_rename(path, &patched_data) { codesign_adhoc(path)?; return Ok(()); } } return Err(AppError::io(path, e)); } codesign_adhoc(path)?; Ok(()) } #[cfg(target_os = "windows")] fn write_via_rename(target: &Path, data: &[u8]) -> Result<(), AppError> { let tmp_path = target.with_extension("tmp"); let old_path = target.with_extension("old"); let _ = std::fs::remove_file(&tmp_path); let _ = std::fs::remove_file(&old_path); std::fs::write(&tmp_path, data).map_err(|e| AppError::io(&tmp_path, e))?; std::fs::rename(target, &old_path).map_err(|e| AppError::io(target, e))?; std::fs::rename(&tmp_path, target).map_err(|e| AppError::io(target, e))?; let _ = std::fs::remove_file(&old_path); Ok(()) } fn restore_single_file(path: &Path) -> Result<(), AppError> { let current_status = get_patch_status_for_path(path); if matches!(current_status, Some("unpatched")) { return Ok(()); } // Try centralized backup directory first let backup_path = get_backup_path(path)?; // Fallback: legacy backup path (adjacent `.toolsearch-bak` file) let legacy_backup = PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)); let actual_backup = if backup_path.is_file() { &backup_path } else if legacy_backup.is_file() { &legacy_backup } else { return Err(AppError::Config(format!( "No backup found for {}", path.display() ))); }; let backup_data = std::fs::read(actual_backup).map_err(|e| AppError::io(actual_backup, e))?; if let Err(e) = std::fs::write(path, &backup_data) { #[cfg(target_os = "windows")] { if let Ok(()) = write_via_rename(path, &backup_data) { codesign_adhoc(path)?; return Ok(()); } } return Err(AppError::io(path, e)); } codesign_adhoc(path)?; Ok(()) } // ── Public API ────────────────────────────────────────────────────── /// Check Tool Search patch status for the current active Claude Code installation. pub fn check_toolsearch_status() -> Result { let installations = find_active_installation() .into_iter() .map(|(path, source)| { let data = std::fs::read(&path).unwrap_or_default(); let status = get_patch_status(&data); // Check centralized backup first, then legacy let has_backup = get_backup_path(&path).map(|p| p.is_file()).unwrap_or(false) || PathBuf::from(format!("{}{}", path.display(), BACKUP_SUFFIX)).is_file(); ClaudeInstallation { path: path.to_string_lossy().to_string(), source: source.clone(), patched: status == "patched", has_backup, } }) .collect::>(); let any_found = !installations.is_empty(); let all_patched = any_found && installations.iter().all(|i| i.patched); Ok(ToolSearchStatus { installations, all_patched, any_found, }) } /// Apply the Tool Search patch to the current active Claude Code installation. pub fn apply_toolsearch_patch() -> Result, AppError> { let (path, _) = require_active_installation()?; Ok(vec![match patch_single_file(&path) { Ok(()) => PatchResult { path: path.to_string_lossy().to_string(), success: true, error: None, }, Err(e) => PatchResult { path: path.to_string_lossy().to_string(), success: false, error: Some(e.to_string()), }, }]) } /// Restore the current active Claude Code installation from backup. pub fn restore_toolsearch_patch() -> Result, AppError> { let (path, _) = require_active_installation()?; Ok(vec![match restore_single_file(&path) { Ok(()) => PatchResult { path: path.to_string_lossy().to_string(), success: true, error: None, }, Err(e) => PatchResult { path: path.to_string_lossy().to_string(), success: false, error: Some(e.to_string()), }, }]) } #[cfg(test)] mod tests { use super::*; #[test] fn test_patch_bytes_replaces_correctly() { let input = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; let (patched, count) = patch_bytes(input).unwrap(); assert_eq!(count, 1); assert_eq!(patched.len(), input.len()); assert!(patched.starts_with(b"return!0/*")); assert!(patched.ends_with(b"*/}catch{return!0}")); } #[test] fn test_patch_status_detection() { let unpatched = br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#; assert_eq!(get_patch_status(unpatched), "unpatched"); let (patched, _) = patch_bytes(unpatched).unwrap(); assert_eq!(get_patch_status(&patched), "patched"); assert_eq!(get_patch_status(b"some random data"), "unknown"); } #[test] fn test_build_patched_bytes_length() { for len in 50..70 { let result = build_patched_bytes(len).unwrap(); assert_eq!(result.len(), len); } } #[test] fn test_resolve_active_patch_target_from_npm_style_bin_path() { let tmp = tempfile::tempdir().unwrap(); let prefix = tmp.path().join("prefix"); let bin_dir = prefix.join("bin"); let pkg_dir = prefix .join("lib") .join("node_modules") .join("@anthropic-ai") .join("claude-code"); std::fs::create_dir_all(&bin_dir).unwrap(); std::fs::create_dir_all(&pkg_dir).unwrap(); let command_path = bin_dir.join("claude"); std::fs::write(&command_path, b"#!/usr/bin/env node\n").unwrap(); let cli_path = pkg_dir.join("cli.js"); std::fs::write( &cli_path, br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, ) .unwrap(); assert_eq!( resolve_active_patch_target(&command_path), Some(resolve_target(&cli_path)) ); } #[test] fn test_restore_single_file_is_noop_when_current_target_is_unpatched() { let tmp = tempfile::NamedTempFile::new().unwrap(); std::fs::write( tmp.path(), br#"return["api.anthropic.com"].includes(x)}catch{return!1}"#, ) .unwrap(); assert!(restore_single_file(tmp.path()).is_ok()); } }