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 for key in ["new_text", "new_body"] {
54 if let Some(s) = map.get(key).and_then(Value::as_str) {
55 parts.push(s);
56 }
57 }
58 if let Some(ops) = map.get("ops").and_then(Value::as_array) {
59 for op in ops {
60 if let Some(s) = op.get("new_text").and_then(Value::as_str) {
61 parts.push(s);
62 }
63 }
64 }
65 if parts.is_empty() {
66 None
67 } else {
68 Some(parts.join("\n"))
69 }
70}
71
72pub struct EgressConfig {
75 forbidden: Vec<(String, Regex)>,
77 block_secrets: bool,
78 pub max_writes_per_min: Option<u32>,
80}
81
82impl Default for EgressConfig {
83 fn default() -> Self {
84 Self::off()
85 }
86}
87
88impl EgressConfig {
89 #[must_use]
91 pub fn off() -> Self {
92 Self {
93 forbidden: Vec::new(),
94 block_secrets: false,
95 max_writes_per_min: None,
96 }
97 }
98
99 #[must_use]
102 pub fn new(
103 forbidden_patterns: &[String],
104 block_secrets: bool,
105 max_writes_per_min: Option<u32>,
106 ) -> Self {
107 let forbidden = forbidden_patterns
108 .iter()
109 .filter_map(|p| Regex::new(p).ok().map(|re| (p.clone(), re)))
110 .collect();
111 Self {
112 forbidden,
113 block_secrets,
114 max_writes_per_min,
115 }
116 }
117
118 #[must_use]
120 pub fn is_active(&self) -> bool {
121 !self.forbidden.is_empty() || self.block_secrets || self.max_writes_per_min.is_some()
122 }
123
124 #[must_use]
129 pub fn check_content(&self, content: &str, redaction: &[(String, Regex)]) -> Option<String> {
130 for (source, re) in &self.forbidden {
131 if re.is_match(content) {
132 return Some(format!("forbidden-pattern:{source}"));
133 }
134 }
135 if self.block_secrets {
136 let (_, hits) = crate::core::redaction::redact_with_patterns(content, redaction);
137 if hits > 0 {
138 return Some("secret".to_string());
139 }
140 if let Some((class, _)) = crate::core::input_filters::pii::detect(content).first() {
141 return Some(format!("pii:{class}"));
142 }
143 }
144 None
145 }
146}
147
148#[must_use]
152pub fn check_rate(max_per_min: u32) -> bool {
153 let mut q = rate_state().lock().expect("egress rate state poisoned");
154 within_limit(&mut q, Instant::now(), max_per_min)
155}
156
157fn rate_state() -> &'static Mutex<VecDeque<Instant>> {
158 static STATE: OnceLock<Mutex<VecDeque<Instant>>> = OnceLock::new();
159 STATE.get_or_init(|| Mutex::new(VecDeque::new()))
160}
161
162fn within_limit(q: &mut VecDeque<Instant>, now: Instant, max_per_min: u32) -> bool {
165 while let Some(&front) = q.front() {
166 if now.duration_since(front).as_secs() >= 60 {
167 q.pop_front();
168 } else {
169 break;
170 }
171 }
172 if u32::try_from(q.len()).unwrap_or(u32::MAX) >= max_per_min {
173 return false;
174 }
175 q.push_back(now);
176 true
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use std::time::Duration;
183
184 fn cfg(patterns: &[&str], block_secrets: bool) -> EgressConfig {
185 let pats: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
186 EgressConfig::new(&pats, block_secrets, None)
187 }
188
189 #[test]
190 fn off_config_is_inactive() {
191 assert!(!EgressConfig::off().is_active());
192 }
193
194 #[test]
195 fn forbidden_pattern_blocks_action() {
196 let c = cfg(&[r"prod\.db\.internal"], false);
197 let reason = c.check_content("psql postgres://prod.db.internal/main", &[]);
198 assert_eq!(
199 reason.as_deref(),
200 Some("forbidden-pattern:prod\\.db\\.internal")
201 );
202 }
203
204 #[test]
205 fn clean_content_is_allowed() {
206 let c = cfg(&[r"prod\.db\.internal"], true);
207 assert!(
208 c.check_content("fn main() { println!(\"hi\"); }", &[])
209 .is_none()
210 );
211 }
212
213 #[test]
214 fn block_secrets_catches_pii() {
215 let c = cfg(&[], true);
216 let reason = c.check_content("email jane@example.com into config", &[]);
217 assert_eq!(reason.as_deref(), Some("pii:email"));
218 }
219
220 #[test]
221 fn block_secrets_catches_redaction_pattern() {
222 let c = cfg(&[], true);
223 let redaction = vec![("employee_id".to_string(), Regex::new(r"EMP-\d{4}").unwrap())];
224 let reason = c.check_content("commit by EMP-1234", &redaction);
225 assert_eq!(reason.as_deref(), Some("secret"));
226 }
227
228 #[test]
229 fn rate_limit_triggers_after_max() {
230 let mut q = VecDeque::new();
231 let now = Instant::now();
232 assert!(within_limit(&mut q, now, 2));
233 assert!(within_limit(&mut q, now, 2));
234 assert!(!within_limit(&mut q, now, 2));
236 }
237
238 #[test]
239 fn rate_limit_window_slides() {
240 let mut q = VecDeque::new();
241 let base = Instant::now();
242 assert!(within_limit(&mut q, base, 1));
243 assert!(!within_limit(&mut q, base, 1));
245 assert!(within_limit(&mut q, base + Duration::from_secs(61), 1));
247 }
248
249 fn args(v: Value) -> Map<String, Value> {
250 match v {
251 Value::Object(m) => m,
252 _ => panic!("expected object"),
253 }
254 }
255
256 #[test]
257 fn write_payload_covers_edit_shell_and_execute() {
258 let edit = args(serde_json::json!({"new_string": "body"}));
259 assert_eq!(
260 write_payload("ctx_edit", Some(&edit)),
261 Some(("body".to_string(), "Write"))
262 );
263 let sh = args(serde_json::json!({"command": "rm -rf /tmp/x"}));
264 assert_eq!(
265 write_payload("ctx_shell", Some(&sh)),
266 Some(("rm -rf /tmp/x".to_string(), "Action"))
267 );
268 assert_eq!(
269 write_payload("ctx_execute", Some(&sh)),
270 Some(("rm -rf /tmp/x".to_string(), "Action"))
271 );
272 assert_eq!(write_payload("ctx_read", Some(&sh)), None);
273 }
274
275 #[test]
276 fn write_payload_collects_every_patch_body() {
277 let single = args(serde_json::json!({"op": "set_line", "new_text": "top"}));
281 assert_eq!(
282 write_payload("ctx_patch", Some(&single)),
283 Some(("top".to_string(), "Write"))
284 );
285
286 let symbol = args(serde_json::json!({"op": "replace_symbol", "new_body": "fn x() {}"}));
287 assert_eq!(
288 write_payload("ctx_patch", Some(&symbol)),
289 Some(("fn x() {}".to_string(), "Write"))
290 );
291
292 let batch = args(serde_json::json!({"ops": [
293 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "first"},
294 {"op": "insert_after", "line": 2, "hash": "bb", "new_text": "second"}
295 ]}));
296 let (payload, kind) = write_payload("ctx_patch", Some(&batch)).unwrap();
297 assert_eq!(kind, "Write");
298 assert!(payload.contains("first") && payload.contains("second"));
299
300 let empty = args(serde_json::json!({"op": "delete", "line": 3, "hash": "cc"}));
302 assert_eq!(write_payload("ctx_patch", Some(&empty)), None);
303 }
304
305 #[test]
306 fn patch_payload_is_checkable_content() {
307 let c = cfg(&[r"prod\.db\.internal"], false);
309 let batch = args(serde_json::json!({"ops": [
310 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "safe"},
311 {"op": "set_line", "line": 9, "hash": "bb", "new_text": "url = prod.db.internal"}
312 ]}));
313 let (payload, _) = write_payload("ctx_patch", Some(&batch)).unwrap();
314 assert!(c.check_content(&payload, &[]).is_some());
315 }
316}