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