Skip to main content

a3s_code_core/
undercover.rs

1//! Undercover mode for safe public repository operations
2//!
3//! When active, adds safety instructions to commit/PR prompts and strips
4//! attribution to avoid leaking internal model codenames, project names,
5//! or other internal information.
6//!
7//! ## Activation
8//!
9//! - `A3S_UNDERCOVER=1` — force ON (even in internal repos)
10//! - Otherwise AUTO: active UNLESS the repo remote matches allowlist
11//! - There is NO force-OFF — safe default
12
13use crate::prompts::UNDERCOVER_INSTRUCTIONS;
14use std::path::Path;
15use std::sync::RwLock;
16
17/// Undercover mode status
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum UndercoverStatus {
20    /// Actively operating undercover
21    Active,
22    /// Not undercover
23    Inactive,
24    /// Status not yet determined
25    Unknown,
26}
27
28/// Internal repository classification
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum RepoClass {
31    /// Internal A3S repository
32    Internal,
33    /// Public/open-source repository
34    External,
35    /// Not a git repository or cannot determine
36    None,
37}
38
39/// Undercover service for managing safe operations
40pub struct UndercoverService {
41    /// Current status
42    status: RwLock<UndercoverStatus>,
43    /// Internal repo allowlist
44    internal_domains: Vec<String>,
45    /// User instructions to inject
46    instructions: String,
47}
48
49impl UndercoverService {
50    /// Create a new undercover service with default configuration
51    pub fn new() -> Self {
52        Self::with_internal_domains(vec![
53            "github.com/A3S-Lab".to_string(),
54            "github.com/anthropics".to_string(),
55        ])
56    }
57
58    /// Create with custom internal domain list
59    pub fn with_internal_domains(domains: Vec<String>) -> Self {
60        Self {
61            status: RwLock::new(UndercoverStatus::Unknown),
62            internal_domains: domains,
63            instructions: UNDERCOVER_INSTRUCTIONS.to_string(),
64        }
65    }
66
67    /// Determine if undercover mode is active for the given repo
68    pub fn is_active(&self, repo_path: &Path) -> bool {
69        // Check force env var first
70        if std::env::var("A3S_UNDERCOVER")
71            .map(|v| v == "1")
72            .unwrap_or(false)
73        {
74            *self.status.write().unwrap() = UndercoverStatus::Active;
75            return true;
76        }
77
78        // Classify the repository
79        let class = self.classify_repo(repo_path);
80
81        let active = class != RepoClass::Internal;
82        *self.status.write().unwrap() = if active {
83            UndercoverStatus::Active
84        } else {
85            UndercoverStatus::Inactive
86        };
87
88        active
89    }
90
91    /// Classify a repository as internal or external
92    pub fn classify_repo(&self, repo_path: &Path) -> RepoClass {
93        let git_dir = repo_path.join(".git");
94        if !git_dir.exists() {
95            return RepoClass::None;
96        }
97
98        // Read the remote URL
99        let remote_url = Self::get_remote_url(repo_path);
100        let Some(url) = remote_url else {
101            return RepoClass::None;
102        };
103
104        // Check against internal domain allowlist
105        for domain in &self.internal_domains {
106            if url.contains(domain) {
107                return RepoClass::Internal;
108            }
109        }
110
111        RepoClass::External
112    }
113
114    /// Get the remote URL for a repository
115    fn get_remote_url(repo_path: &Path) -> Option<String> {
116        // Try to read .git/config
117        let config_path = repo_path.join(".git").join("config");
118        let config = std::fs::read_to_string(&config_path).ok()?;
119
120        // Find [remote "origin"] section and extract url
121        let mut in_origin = false;
122        for line in config.lines() {
123            let line = line.trim();
124            if line == "[remote \"origin\"]" {
125                in_origin = true;
126            } else if line.starts_with('[') && in_origin {
127                break;
128            } else if in_origin && line.starts_with("url = ") {
129                return Some(line[6..].to_string());
130            }
131        }
132
133        None
134    }
135
136    /// Get current status
137    pub fn status(&self) -> UndercoverStatus {
138        *self.status.read().unwrap()
139    }
140
141    /// Get undercover instructions to prepend to commit messages
142    pub fn get_instructions(&self) -> String {
143        if self.status() == UndercoverStatus::Active {
144            self.instructions.clone()
145        } else {
146            String::new()
147        }
148    }
149
150    /// Sanitize a commit message by removing internal references
151    pub fn sanitize_commit_message(&self, message: &str) -> String {
152        if self.status() != UndercoverStatus::Active {
153            return message.to_string();
154        }
155
156        let mut result = message.to_string();
157
158        // Remove Co-Authored-By lines
159        result = result
160            .lines()
161            .filter(|line| !line.trim().starts_with("Co-Authored-By:"))
162            .collect::<Vec<_>>()
163            .join("\n");
164
165        // Remove any lines containing internal codenames
166        let codename_patterns = [
167            "claude-opus",
168            "claude-sonnet",
169            "claude-haiku",
170            "capybara",
171            "tengu",
172            "claw-code",
173            "a3s-code",
174        ];
175
176        for pattern in codename_patterns {
177            result = result
178                .lines()
179                .filter(|line| !line.to_lowercase().contains(pattern))
180                .collect::<Vec<_>>()
181                .join("\n");
182        }
183
184        result.trim().to_string()
185    }
186
187    /// Check if a string contains internal references
188    pub fn contains_internal_refs(&self, text: &str) -> bool {
189        let codename_patterns = [
190            "claude-opus",
191            "claude-sonnet",
192            "claude-haiku",
193            "capybara",
194            "tengu",
195            "claw-code",
196            "a3s-code",
197            "co-authored-by:",
198        ];
199
200        let text_lower = text.to_lowercase();
201        codename_patterns.iter().any(|p| text_lower.contains(p))
202    }
203}
204
205impl Default for UndercoverService {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl Clone for UndercoverService {
212    fn clone(&self) -> Self {
213        Self {
214            status: RwLock::new(*self.status.read().unwrap()),
215            internal_domains: self.internal_domains.clone(),
216            instructions: self.instructions.clone(),
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use std::path::PathBuf;
225
226    #[test]
227    fn test_classify_nonexistent() {
228        let service = UndercoverService::new();
229        let result = service.classify_repo(&PathBuf::from("/nonexistent/path"));
230        assert_eq!(result, RepoClass::None);
231    }
232
233    #[test]
234    fn test_sanitize_commit_message() {
235        let service = UndercoverService::new();
236        // Force active status
237        *service.status.write().unwrap() = UndercoverStatus::Active;
238
239        let dirty = "Fix bug\nCo-Authored-By: Claude <claude@example.com>\nGenerated with a3s-code";
240        let clean = service.sanitize_commit_message(dirty);
241
242        assert!(!clean.contains("Co-Authored-By"));
243        assert!(!clean.contains("a3s-code"));
244        assert!(clean.contains("Fix bug"));
245    }
246
247    #[test]
248    fn test_contains_internal_refs() {
249        let service = UndercoverService::new();
250
251        assert!(service.contains_internal_refs("Using claude-opus-4-6"));
252        assert!(service.contains_internal_refs("Co-Authored-By: Claude"));
253        assert!(!service.contains_internal_refs("Fix parser bug"));
254    }
255
256    #[test]
257    fn test_get_instructions_when_inactive() {
258        let service = UndercoverService::new();
259        *service.status.write().unwrap() = UndercoverStatus::Inactive;
260        assert!(service.get_instructions().is_empty());
261    }
262
263    #[test]
264    fn test_get_instructions_when_active() {
265        let service = UndercoverService::new();
266        *service.status.write().unwrap() = UndercoverStatus::Active;
267        assert!(!service.get_instructions().is_empty());
268        assert!(service.get_instructions().contains("UNDERCOVER MODE"));
269    }
270
271    #[test]
272    fn test_undercover_service_default() {
273        let service = UndercoverService::default();
274        assert_eq!(service.status(), UndercoverStatus::Unknown);
275    }
276
277    #[test]
278    fn test_undercover_service_clone() {
279        let service = UndercoverService::new();
280        let cloned = service.clone();
281        assert_eq!(cloned.status(), service.status());
282    }
283
284    #[test]
285    fn test_undercover_service_with_custom_domains() {
286        let service =
287            UndercoverService::with_internal_domains(vec!["github.com/custom".to_string()]);
288        // Should not contain the default anthropics domain
289        // but should have the custom one
290        let result = service.classify_repo(&PathBuf::from("/nonexistent"));
291        assert_eq!(result, RepoClass::None);
292    }
293
294    #[test]
295    fn test_undercover_status_debug() {
296        assert_eq!(format!("{:?}", UndercoverStatus::Active), "Active");
297        assert_eq!(format!("{:?}", UndercoverStatus::Inactive), "Inactive");
298        assert_eq!(format!("{:?}", UndercoverStatus::Unknown), "Unknown");
299    }
300
301    #[test]
302    fn test_repo_class_debug() {
303        assert_eq!(format!("{:?}", RepoClass::Internal), "Internal");
304        assert_eq!(format!("{:?}", RepoClass::External), "External");
305        assert_eq!(format!("{:?}", RepoClass::None), "None");
306    }
307
308    #[test]
309    fn test_sanitize_commit_message_preserves_good_content() {
310        let service = UndercoverService::new();
311        *service.status.write().unwrap() = UndercoverStatus::Active;
312
313        let msg = "Fix race condition in file watcher initialization";
314        let clean = service.sanitize_commit_message(msg);
315        assert_eq!(clean, msg);
316    }
317
318    #[test]
319    fn test_sanitize_removes_multiple_internal_refs() {
320        let service = UndercoverService::new();
321        *service.status.write().unwrap() = UndercoverStatus::Active;
322
323        let dirty = "Fix bug\nCo-Authored-By: Claude\nclaude-opus was used\na3s-code";
324        let clean = service.sanitize_commit_message(dirty);
325
326        assert!(!clean.contains("Co-Authored-By"));
327        assert!(!clean.contains("claude-opus"));
328        assert!(!clean.contains("a3s-code"));
329        assert!(clean.contains("Fix bug"));
330    }
331
332    #[test]
333    fn test_sanitize_ignores_when_inactive() {
334        let service = UndercoverService::new();
335        *service.status.write().unwrap() = UndercoverStatus::Inactive;
336
337        let msg = "Co-Authored-By: Claude";
338        let clean = service.sanitize_commit_message(msg);
339        // When inactive, no sanitization happens
340        assert_eq!(clean, msg);
341    }
342
343    #[test]
344    fn test_contains_internal_refs_case_insensitive() {
345        let service = UndercoverService::new();
346
347        assert!(service.contains_internal_refs("CLAUDE-OPUS"));
348        assert!(service.contains_internal_refs("A3S-CODE"));
349        assert!(service.contains_internal_refs("Co-Authored-By:"));
350    }
351
352    #[test]
353    fn test_contains_internal_refs_all_patterns() {
354        let service = UndercoverService::new();
355
356        assert!(service.contains_internal_refs("claude-sonnet"));
357        assert!(service.contains_internal_refs("claude-haiku"));
358        assert!(service.contains_internal_refs("capybara"));
359        assert!(service.contains_internal_refs("tengu"));
360        assert!(service.contains_internal_refs("claw-code"));
361    }
362
363    #[test]
364    fn test_is_active_unknown_repo_returns_true() {
365        let service = UndercoverService::new();
366        // Non-existent path should return true (safe default)
367        let result = service.is_active(&PathBuf::from("/nonexistent/path"));
368        assert!(result);
369    }
370}