Skip to main content

lean_ctx/core/
egress.rs

1//! Egress / output DLP for agent writes & actions (GL #676) — the *output*
2//! side of the Great Filter.
3//!
4//! Where [`crate::core::input_filters`] governs what reaches the agent, this
5//! module governs what the agent *emits*: file writes (`ctx_edit`) and shell
6//! actions (`ctx_shell`). It runs **before** the tool executes, so a blocked
7//! write never touches disk and a blocked command never runs.
8//!
9//! Driven by the active pack's `[egress]` section ([`crate::core::policy`]):
10//! - `forbidden_patterns` — regexes that, if matched, block the write/action
11//!   (e.g. a direct prod-DB DSN);
12//! - `block_secrets` — refuse content carrying detected secrets/PII (reusing the
13//!   pack redaction patterns + [`crate::core::input_filters::pii`]);
14//! - `max_writes_per_min` — a per-process sliding-window rate limit on actions.
15//!
16//! **Local-Free:** only the agent's tool-driven egress is gated; a human's
17//! manual edits never pass through this path.
18
19use std::collections::VecDeque;
20use std::sync::{Mutex, OnceLock};
21use std::time::Instant;
22
23use regex::Regex;
24use serde_json::{Map, Value};
25
26/// Map a write/action tool call to the outbound payload the egress DLP must
27/// inspect, plus its audit kind ("Write"/"Action"). Single source of truth for
28/// the MCP dispatch gate (`server::call_tool`) AND the CLI `policy enforce`
29/// mirror, so the two gates can never drift. `None` → the tool carries no
30/// governed egress payload.
31///
32/// `ctx_patch` (#1008) can carry several write bodies in one call — top-level
33/// `new_text` (anchored ops / create / replace_symbol) and each `ops[].new_text`
34/// of a batch — all are concatenated for inspection.
35#[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
49/// Every write body a `ctx_patch` call may carry, joined for one inspection.
50fn 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
70/// Resolved, ready-to-run egress configuration. Forbidden-pattern regexes are
71/// compiled once at policy load, off the hot path.
72pub struct EgressConfig {
73    /// `(source, compiled)` — source kept for the (non-sensitive) audit reason.
74    forbidden: Vec<(String, Regex)>,
75    block_secrets: bool,
76    /// Max agent write/action tool calls per 60 s; `None` = unlimited.
77    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    /// A no-op config.
88    #[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    /// Build from resolved policy. Invalid regexes are skipped (validation
98    /// already rejects them at load — defense in depth).
99    #[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    /// True if any egress rule is configured (cheap hot-path gate).
117    #[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    /// Inspect outbound `content` (a write body or a shell command). Returns a
123    /// privacy-preserving block reason (pattern source / class — never the
124    /// matched value), or `None` to allow. `redaction` are the active pack's
125    /// compiled secret patterns, consulted when `block_secrets` is set.
126    #[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/// Per-process sliding-window rate check. Records the action and returns `true`
147/// when within `max_per_min`, or `false` (without recording) when the limit is
148/// already reached in the trailing 60 s. A poisoned state fails closed: egress
149/// remains blocked rather than panicking or bypassing the configured limit.
150#[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
167/// Pure sliding-window decision (testable without the global state): prune
168/// entries older than 60 s, then admit + record if under `max_per_min`.
169fn 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_catches_redaction_pattern() {
227        let c = cfg(&[], true);
228        let redaction = vec![("employee_id".to_string(), Regex::new(r"EMP-\d{4}").unwrap())];
229        let reason = c.check_content("commit by EMP-1234", &redaction);
230        assert_eq!(reason.as_deref(), Some("secret"));
231    }
232
233    #[test]
234    fn rate_limit_triggers_after_max() {
235        let mut q = VecDeque::new();
236        let now = Instant::now();
237        assert!(within_limit(&mut q, now, 2));
238        assert!(within_limit(&mut q, now, 2));
239        // Third within the window is refused.
240        assert!(!within_limit(&mut q, now, 2));
241    }
242
243    #[test]
244    fn rate_limit_window_slides() {
245        let mut q = VecDeque::new();
246        let base = Instant::now();
247        assert!(within_limit(&mut q, base, 1));
248        // Same instant: over limit.
249        assert!(!within_limit(&mut q, base, 1));
250        // 61 s later the old entry has aged out → admitted again.
251        assert!(within_limit(&mut q, base + Duration::from_secs(61), 1));
252    }
253
254    #[test]
255    fn poisoned_rate_state_fails_closed_without_panicking() {
256        let state = std::sync::Arc::new(Mutex::new(VecDeque::new()));
257        let poisoner = std::sync::Arc::clone(&state);
258        let _ = std::thread::spawn(move || {
259            let _guard = poisoner.lock().expect("fresh mutex");
260            panic!("poison rate state");
261        })
262        .join();
263
264        assert!(!check_rate_at(&state, Instant::now(), 1));
265    }
266
267    fn args(v: Value) -> Map<String, Value> {
268        match v {
269            Value::Object(m) => m,
270            _ => panic!("expected object"),
271        }
272    }
273
274    #[test]
275    fn write_payload_covers_edit_shell_and_execute() {
276        let edit = args(serde_json::json!({"new_string": "body"}));
277        assert_eq!(
278            write_payload("ctx_edit", Some(&edit)),
279            Some(("body".to_string(), "Write"))
280        );
281        let sh = args(serde_json::json!({"command": "rm -rf /tmp/x"}));
282        assert_eq!(
283            write_payload("ctx_shell", Some(&sh)),
284            Some(("rm -rf /tmp/x".to_string(), "Action"))
285        );
286        assert_eq!(
287            write_payload("ctx_execute", Some(&sh)),
288            Some(("rm -rf /tmp/x".to_string(), "Action"))
289        );
290        assert_eq!(write_payload("ctx_read", Some(&sh)), None);
291    }
292
293    #[test]
294    fn write_payload_collects_every_patch_body() {
295        // #1008 security pass: single op, replace_symbol AND every batch op body
296        // must all be inspected — a secret in ops[1] is as forbidden as one in
297        // a top-level new_text.
298        let single = args(serde_json::json!({"op": "set_line", "new_text": "top"}));
299        assert_eq!(
300            write_payload("ctx_patch", Some(&single)),
301            Some(("top".to_string(), "Write"))
302        );
303
304        let symbol = args(serde_json::json!({"op": "replace_symbol", "new_text": "fn x() {}"}));
305        assert_eq!(
306            write_payload("ctx_patch", Some(&symbol)),
307            Some(("fn x() {}".to_string(), "Write"))
308        );
309
310        let batch = args(serde_json::json!({"ops": [
311            {"op": "set_line", "line": 1, "hash": "aa", "new_text": "first"},
312            {"op": "insert_after", "line": 2, "hash": "bb", "new_text": "second"}
313        ]}));
314        let (payload, kind) = write_payload("ctx_patch", Some(&batch)).unwrap();
315        assert_eq!(kind, "Write");
316        assert!(payload.contains("first") && payload.contains("second"));
317
318        // No write body (e.g. a malformed call) → nothing to inspect.
319        let empty = args(serde_json::json!({"op": "delete", "line": 3, "hash": "cc"}));
320        assert_eq!(write_payload("ctx_patch", Some(&empty)), None);
321    }
322
323    #[test]
324    fn patch_payload_is_checkable_content() {
325        // End-to-end shape: a forbidden pattern hidden in a batch op is caught.
326        let c = cfg(&[r"prod\.db\.internal"], false);
327        let batch = args(serde_json::json!({"ops": [
328            {"op": "set_line", "line": 1, "hash": "aa", "new_text": "safe"},
329            {"op": "set_line", "line": 9, "hash": "bb", "new_text": "url = prod.db.internal"}
330        ]}));
331        let (payload, _) = write_payload("ctx_patch", Some(&batch)).unwrap();
332        assert!(c.check_content(&payload, &[]).is_some());
333    }
334}