1use std::collections::VecDeque;
20use std::sync::{Mutex, OnceLock};
21use std::time::Instant;
22
23use regex::Regex;
24use serde_json::{Map, Value};
25
26#[must_use]
36pub fn write_payload(
37 tool: &str,
38 args: Option<&Map<String, Value>>,
39) -> Option<(String, &'static str)> {
40 let get = |k: &str| args?.get(k)?.as_str().map(String::from);
41 match tool {
42 "ctx_edit" => get("new_string").map(|s| (s, "Write")),
43 "ctx_patch" => patch_payload(args).map(|s| (s, "Write")),
44 "ctx_shell" | "ctx_execute" => get("command").map(|s| (s, "Action")),
45 _ => None,
46 }
47}
48
49fn patch_payload(args: Option<&Map<String, Value>>) -> Option<String> {
51 let map = args?;
52 let mut parts: Vec<&str> = Vec::new();
53 if let Some(s) = map.get("new_text").and_then(Value::as_str) {
54 parts.push(s);
55 }
56 if let Some(ops) = map.get("ops").and_then(Value::as_array) {
57 for op in ops {
58 if let Some(s) = op.get("new_text").and_then(Value::as_str) {
59 parts.push(s);
60 }
61 }
62 }
63 if parts.is_empty() {
64 None
65 } else {
66 Some(parts.join("\n"))
67 }
68}
69
70pub struct EgressConfig {
73 forbidden: Vec<(String, Regex)>,
75 block_secrets: bool,
76 pub max_writes_per_min: Option<u32>,
78}
79
80impl Default for EgressConfig {
81 fn default() -> Self {
82 Self::off()
83 }
84}
85
86impl EgressConfig {
87 #[must_use]
89 pub fn off() -> Self {
90 Self {
91 forbidden: Vec::new(),
92 block_secrets: false,
93 max_writes_per_min: None,
94 }
95 }
96
97 #[must_use]
100 pub fn new(
101 forbidden_patterns: &[String],
102 block_secrets: bool,
103 max_writes_per_min: Option<u32>,
104 ) -> Self {
105 let forbidden = forbidden_patterns
106 .iter()
107 .filter_map(|p| Regex::new(p).ok().map(|re| (p.clone(), re)))
108 .collect();
109 Self {
110 forbidden,
111 block_secrets,
112 max_writes_per_min,
113 }
114 }
115
116 #[must_use]
118 pub fn is_active(&self) -> bool {
119 !self.forbidden.is_empty() || self.block_secrets || self.max_writes_per_min.is_some()
120 }
121
122 #[must_use]
127 pub fn check_content(&self, content: &str, redaction: &[(String, Regex)]) -> Option<String> {
128 for (source, re) in &self.forbidden {
129 if re.is_match(content) {
130 return Some(format!("forbidden-pattern:{source}"));
131 }
132 }
133 if self.block_secrets {
134 let (_, hits) = crate::core::redaction::redact_with_patterns(content, redaction);
135 if hits > 0 {
136 return Some("secret".to_string());
137 }
138 if let Some((class, _)) = crate::core::input_filters::pii::detect(content).first() {
139 return Some(format!("pii:{class}"));
140 }
141 }
142 None
143 }
144}
145
146#[must_use]
151pub fn check_rate(max_per_min: u32) -> bool {
152 check_rate_at(rate_state(), Instant::now(), max_per_min)
153}
154
155fn check_rate_at(state: &Mutex<VecDeque<Instant>>, now: Instant, max_per_min: u32) -> bool {
156 let Ok(mut q) = state.lock() else {
157 return false;
158 };
159 within_limit(&mut q, now, max_per_min)
160}
161
162fn rate_state() -> &'static Mutex<VecDeque<Instant>> {
163 static STATE: OnceLock<Mutex<VecDeque<Instant>>> = OnceLock::new();
164 STATE.get_or_init(|| Mutex::new(VecDeque::new()))
165}
166
167fn within_limit(q: &mut VecDeque<Instant>, now: Instant, max_per_min: u32) -> bool {
170 while let Some(&front) = q.front() {
171 if now.duration_since(front).as_secs() >= 60 {
172 q.pop_front();
173 } else {
174 break;
175 }
176 }
177 if u32::try_from(q.len()).unwrap_or(u32::MAX) >= max_per_min {
178 return false;
179 }
180 q.push_back(now);
181 true
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::time::Duration;
188
189 fn cfg(patterns: &[&str], block_secrets: bool) -> EgressConfig {
190 let pats: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
191 EgressConfig::new(&pats, block_secrets, None)
192 }
193
194 #[test]
195 fn off_config_is_inactive() {
196 assert!(!EgressConfig::off().is_active());
197 }
198
199 #[test]
200 fn forbidden_pattern_blocks_action() {
201 let c = cfg(&[r"prod\.db\.internal"], false);
202 let reason = c.check_content("psql postgres://prod.db.internal/main", &[]);
203 assert_eq!(
204 reason.as_deref(),
205 Some("forbidden-pattern:prod\\.db\\.internal")
206 );
207 }
208
209 #[test]
210 fn clean_content_is_allowed() {
211 let c = cfg(&[r"prod\.db\.internal"], true);
212 assert!(
213 c.check_content("fn main() { println!(\"hi\"); }", &[])
214 .is_none()
215 );
216 }
217
218 #[test]
219 fn block_secrets_catches_pii() {
220 let c = cfg(&[], true);
221 let reason = c.check_content("email jane@example.com into config", &[]);
222 assert_eq!(reason.as_deref(), Some("pii:email"));
223 }
224
225 #[test]
226 fn block_secrets_distinguishes_repository_numbers_from_phones() {
227 let c = cfg(&[], true);
228 for content in [
229 "fixes #1234",
230 "run 17384920156",
231 "port 8069",
232 "at 14:22:05 UTC",
233 "pattern {0,120} bound",
234 ] {
235 assert_eq!(c.check_content(content, &[]), None, "{content}");
236 }
237 for content in [
238 "call +14155552671",
239 "phone: 6123386000",
240 "call 612-338-6000",
241 "call (612) 338-6000",
242 ] {
243 assert_eq!(
244 c.check_content(content, &[]).as_deref(),
245 Some("pii:phone"),
246 "{content}"
247 );
248 }
249 }
250
251 #[test]
252 fn block_secrets_catches_redaction_pattern() {
253 let c = cfg(&[], true);
254 let redaction = vec![("employee_id".to_string(), Regex::new(r"EMP-\d{4}").unwrap())];
255 let reason = c.check_content("commit by EMP-1234", &redaction);
256 assert_eq!(reason.as_deref(), Some("secret"));
257 }
258
259 #[test]
260 fn rate_limit_triggers_after_max() {
261 let mut q = VecDeque::new();
262 let now = Instant::now();
263 assert!(within_limit(&mut q, now, 2));
264 assert!(within_limit(&mut q, now, 2));
265 assert!(!within_limit(&mut q, now, 2));
267 }
268
269 #[test]
270 fn rate_limit_window_slides() {
271 let mut q = VecDeque::new();
272 let base = Instant::now();
273 assert!(within_limit(&mut q, base, 1));
274 assert!(!within_limit(&mut q, base, 1));
276 assert!(within_limit(&mut q, base + Duration::from_secs(61), 1));
278 }
279
280 #[test]
281 fn poisoned_rate_state_fails_closed_without_panicking() {
282 let state = std::sync::Arc::new(Mutex::new(VecDeque::new()));
283 let poisoner = std::sync::Arc::clone(&state);
284 let _ = std::thread::spawn(move || {
285 let _guard = poisoner.lock().expect("fresh mutex");
286 panic!("poison rate state");
287 })
288 .join();
289
290 assert!(!check_rate_at(&state, Instant::now(), 1));
291 }
292
293 fn args(v: Value) -> Map<String, Value> {
294 match v {
295 Value::Object(m) => m,
296 _ => panic!("expected object"),
297 }
298 }
299
300 #[test]
301 fn write_payload_covers_edit_shell_and_execute() {
302 let edit = args(serde_json::json!({"new_string": "body"}));
303 assert_eq!(
304 write_payload("ctx_edit", Some(&edit)),
305 Some(("body".to_string(), "Write"))
306 );
307 let sh = args(serde_json::json!({"command": "rm -rf /tmp/x"}));
308 assert_eq!(
309 write_payload("ctx_shell", Some(&sh)),
310 Some(("rm -rf /tmp/x".to_string(), "Action"))
311 );
312 assert_eq!(
313 write_payload("ctx_execute", Some(&sh)),
314 Some(("rm -rf /tmp/x".to_string(), "Action"))
315 );
316 assert_eq!(write_payload("ctx_read", Some(&sh)), None);
317 }
318
319 #[test]
320 fn write_payload_collects_every_patch_body() {
321 let single = args(serde_json::json!({"op": "set_line", "new_text": "top"}));
325 assert_eq!(
326 write_payload("ctx_patch", Some(&single)),
327 Some(("top".to_string(), "Write"))
328 );
329
330 let symbol = args(serde_json::json!({"op": "replace_symbol", "new_text": "fn x() {}"}));
331 assert_eq!(
332 write_payload("ctx_patch", Some(&symbol)),
333 Some(("fn x() {}".to_string(), "Write"))
334 );
335
336 let batch = args(serde_json::json!({"ops": [
337 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "first"},
338 {"op": "insert_after", "line": 2, "hash": "bb", "new_text": "second"}
339 ]}));
340 let (payload, kind) = write_payload("ctx_patch", Some(&batch)).unwrap();
341 assert_eq!(kind, "Write");
342 assert!(payload.contains("first") && payload.contains("second"));
343
344 let empty = args(serde_json::json!({"op": "delete", "line": 3, "hash": "cc"}));
346 assert_eq!(write_payload("ctx_patch", Some(&empty)), None);
347 }
348
349 #[test]
350 fn patch_payload_is_checkable_content() {
351 let c = cfg(&[r"prod\.db\.internal"], false);
353 let batch = args(serde_json::json!({"ops": [
354 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "safe"},
355 {"op": "set_line", "line": 9, "hash": "bb", "new_text": "url = prod.db.internal"}
356 ]}));
357 let (payload, _) = write_payload("ctx_patch", Some(&batch)).unwrap();
358 assert!(c.check_content(&payload, &[]).is_some());
359 }
360}