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;
24
25/// Resolved, ready-to-run egress configuration. Forbidden-pattern regexes are
26/// compiled once at policy load, off the hot path.
27pub struct EgressConfig {
28    /// `(source, compiled)` — source kept for the (non-sensitive) audit reason.
29    forbidden: Vec<(String, Regex)>,
30    block_secrets: bool,
31    /// Max agent write/action tool calls per 60 s; `None` = unlimited.
32    pub max_writes_per_min: Option<u32>,
33}
34
35impl Default for EgressConfig {
36    fn default() -> Self {
37        Self::off()
38    }
39}
40
41impl EgressConfig {
42    /// A no-op config.
43    #[must_use]
44    pub fn off() -> Self {
45        Self {
46            forbidden: Vec::new(),
47            block_secrets: false,
48            max_writes_per_min: None,
49        }
50    }
51
52    /// Build from resolved policy. Invalid regexes are skipped (validation
53    /// already rejects them at load — defense in depth).
54    #[must_use]
55    pub fn new(
56        forbidden_patterns: &[String],
57        block_secrets: bool,
58        max_writes_per_min: Option<u32>,
59    ) -> Self {
60        let forbidden = forbidden_patterns
61            .iter()
62            .filter_map(|p| Regex::new(p).ok().map(|re| (p.clone(), re)))
63            .collect();
64        Self {
65            forbidden,
66            block_secrets,
67            max_writes_per_min,
68        }
69    }
70
71    /// True if any egress rule is configured (cheap hot-path gate).
72    #[must_use]
73    pub fn is_active(&self) -> bool {
74        !self.forbidden.is_empty() || self.block_secrets || self.max_writes_per_min.is_some()
75    }
76
77    /// Inspect outbound `content` (a write body or a shell command). Returns a
78    /// privacy-preserving block reason (pattern source / class — never the
79    /// matched value), or `None` to allow. `redaction` are the active pack's
80    /// compiled secret patterns, consulted when `block_secrets` is set.
81    #[must_use]
82    pub fn check_content(&self, content: &str, redaction: &[(String, Regex)]) -> Option<String> {
83        for (source, re) in &self.forbidden {
84            if re.is_match(content) {
85                return Some(format!("forbidden-pattern:{source}"));
86            }
87        }
88        if self.block_secrets {
89            let (_, hits) = crate::core::redaction::redact_with_patterns(content, redaction);
90            if hits > 0 {
91                return Some("secret".to_string());
92            }
93            if let Some((class, _)) = crate::core::input_filters::pii::detect(content).first() {
94                return Some(format!("pii:{class}"));
95            }
96        }
97        None
98    }
99}
100
101/// Per-process sliding-window rate check. Records the action and returns `true`
102/// when within `max_per_min`, or `false` (without recording) when the limit is
103/// already reached in the trailing 60 s.
104#[must_use]
105pub fn check_rate(max_per_min: u32) -> bool {
106    let mut q = rate_state().lock().expect("egress rate state poisoned");
107    within_limit(&mut q, Instant::now(), max_per_min)
108}
109
110fn rate_state() -> &'static Mutex<VecDeque<Instant>> {
111    static STATE: OnceLock<Mutex<VecDeque<Instant>>> = OnceLock::new();
112    STATE.get_or_init(|| Mutex::new(VecDeque::new()))
113}
114
115/// Pure sliding-window decision (testable without the global state): prune
116/// entries older than 60 s, then admit + record if under `max_per_min`.
117fn within_limit(q: &mut VecDeque<Instant>, now: Instant, max_per_min: u32) -> bool {
118    while let Some(&front) = q.front() {
119        if now.duration_since(front).as_secs() >= 60 {
120            q.pop_front();
121        } else {
122            break;
123        }
124    }
125    if u32::try_from(q.len()).unwrap_or(u32::MAX) >= max_per_min {
126        return false;
127    }
128    q.push_back(now);
129    true
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::time::Duration;
136
137    fn cfg(patterns: &[&str], block_secrets: bool) -> EgressConfig {
138        let pats: Vec<String> = patterns.iter().map(|s| (*s).to_string()).collect();
139        EgressConfig::new(&pats, block_secrets, None)
140    }
141
142    #[test]
143    fn off_config_is_inactive() {
144        assert!(!EgressConfig::off().is_active());
145    }
146
147    #[test]
148    fn forbidden_pattern_blocks_action() {
149        let c = cfg(&[r"prod\.db\.internal"], false);
150        let reason = c.check_content("psql postgres://prod.db.internal/main", &[]);
151        assert_eq!(
152            reason.as_deref(),
153            Some("forbidden-pattern:prod\\.db\\.internal")
154        );
155    }
156
157    #[test]
158    fn clean_content_is_allowed() {
159        let c = cfg(&[r"prod\.db\.internal"], true);
160        assert!(
161            c.check_content("fn main() { println!(\"hi\"); }", &[])
162                .is_none()
163        );
164    }
165
166    #[test]
167    fn block_secrets_catches_pii() {
168        let c = cfg(&[], true);
169        let reason = c.check_content("email jane@example.com into config", &[]);
170        assert_eq!(reason.as_deref(), Some("pii:email"));
171    }
172
173    #[test]
174    fn block_secrets_catches_redaction_pattern() {
175        let c = cfg(&[], true);
176        let redaction = vec![("employee_id".to_string(), Regex::new(r"EMP-\d{4}").unwrap())];
177        let reason = c.check_content("commit by EMP-1234", &redaction);
178        assert_eq!(reason.as_deref(), Some("secret"));
179    }
180
181    #[test]
182    fn rate_limit_triggers_after_max() {
183        let mut q = VecDeque::new();
184        let now = Instant::now();
185        assert!(within_limit(&mut q, now, 2));
186        assert!(within_limit(&mut q, now, 2));
187        // Third within the window is refused.
188        assert!(!within_limit(&mut q, now, 2));
189    }
190
191    #[test]
192    fn rate_limit_window_slides() {
193        let mut q = VecDeque::new();
194        let base = Instant::now();
195        assert!(within_limit(&mut q, base, 1));
196        // Same instant: over limit.
197        assert!(!within_limit(&mut q, base, 1));
198        // 61 s later the old entry has aged out → admitted again.
199        assert!(within_limit(&mut q, base + Duration::from_secs(61), 1));
200    }
201}