agentplane/core/egress.rs
1//! Where this plane is permitted to connect.
2//!
3//! The sensitivity lattice controls *what* may leave; this controls
4//! *where it may leave to*. They are different holes. A value can be perfectly
5//! within its ceiling and still be posted to a host nobody granted.
6//!
7//! # The channel this closes
8//!
9//! An MCP server can advertise new tools at any time, and a model provider's
10//! base URL is a string in a config file. Both are places where a *destination*
11//! can appear without anybody deciding it should. That is a self-service egress
12//! channel: discovery of a new capability quietly becomes permission to reach a
13//! new host.
14//!
15//! So destinations are **granted, not discovered**. Discovering that a tool
16//! exists stays fine; reaching a host nobody listed does not.
17//!
18//! # Deny by default, once configured
19//!
20//! An [`Egress`] permits nothing until told otherwise, which is the only default
21//! that fails safe. But the *seam* is optional in exactly the way the policy
22//! engine is: a runtime with no egress policy has none, and that is spelled
23//! `None` rather than as a permissive allowlist. There is deliberately no
24//! `Egress::allow_all()`, for the reason `core::policy` gives for having no
25//! `AllowAll` — a deployment that wants no control should have to say so by
26//! configuring nothing, not by configuring something that looks like a control
27//! and is not.
28//!
29//! # Which drivers ask
30//!
31//! The HTTP model drivers do — Anthropic, `OpenAI`, `Gemini` and the
32//! `OpenAI`-compatible one — each refusing an ungranted base URL before a request
33//! is built, so nothing leaves and nothing is metered. Governed media fetches
34//! and push webhooks ask their own, stricter question, in
35//! [`netguard`](crate::netguard).
36//!
37//! **Bedrock does not**, and the exception is named here rather than left for a
38//! reader to infer from a missing method. That driver is handed a built AWS
39//! client whose endpoint the SDK will not disclose, so the only host this type
40//! could be shown is one derived from the region — which an endpoint override
41//! makes a fiction. Its own documentation states what stands in place of a
42//! check. Any *new* driver reaching a host over HTTP is expected to take an
43//! [`Egress`]; a driver that cannot must say so where it is built, because an
44//! uncovered surface that says it is uncovered gets revisited and one that says
45//! nothing does not.
46//!
47//! # Host, not URL
48//!
49//! Matching is on the **host**, and this type never parses a URL. URL parsing is
50//! where allowlists are broken: `https://evil.example/#@allowed.example` and its
51//! many cousins read one way to a careless splitter and another to a real
52//! parser. The callers here already link a correct parser, so they hand over a
53//! parsed host and this type does set membership on it.
54
55use std::collections::BTreeSet;
56
57/// The hosts this plane may connect to.
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct Egress {
60 hosts: BTreeSet<String>,
61}
62
63impl Egress {
64 /// An allowlist that permits nothing.
65 #[must_use]
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 /// Grant one host.
71 ///
72 /// Hosts are compared case-insensitively, because DNS is, and an allowlist
73 /// that can be bypassed by capitalising a letter is not one.
74 #[must_use]
75 pub fn allow(mut self, host: impl AsRef<str>) -> Self {
76 self.hosts.insert(host.as_ref().to_ascii_lowercase());
77 self
78 }
79
80 #[must_use]
81 pub fn len(&self) -> usize {
82 self.hosts.len()
83 }
84
85 #[must_use]
86 pub fn is_empty(&self) -> bool {
87 self.hosts.is_empty()
88 }
89
90 /// Every granted host, in a stable order.
91 pub fn hosts(&self) -> impl Iterator<Item = &str> {
92 self.hosts.iter().map(String::as_str)
93 }
94
95 /// Whether a connection to `host` is permitted.
96 ///
97 /// No wildcards, and that is deliberate. `*.example.com` reads as a
98 /// convenience and is a grant of every host anybody can register under a
99 /// domain — including one an attacker controls the moment a subdomain is
100 /// dangling. A deployment that genuinely needs many hosts lists them.
101 ///
102 /// # Errors
103 ///
104 /// [`EgressError::NotGranted`], naming the host, so an operator reading the
105 /// journal can see what was attempted rather than only that something was.
106 pub fn permits(&self, host: Option<&str>) -> Result<(), EgressError> {
107 // A destination with no host — a relative URL, a unix socket, a
108 // malformed string — cannot be checked against an allowlist, and
109 // "cannot be checked" resolves to "not permitted".
110 let Some(host) = host else {
111 return Err(EgressError::NoHost);
112 };
113 if self.hosts.contains(&host.to_ascii_lowercase()) {
114 return Ok(());
115 }
116 Err(EgressError::NotGranted {
117 host: host.to_owned(),
118 })
119 }
120}
121
122/// Why a connection was refused before it was made.
123#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
124pub enum EgressError {
125 #[error(
126 "'{host}' is not a granted destination; a host reachable without being \
127 listed is a self-service egress channel"
128 )]
129 NotGranted { host: String },
130
131 #[error("the destination has no host, so it cannot be checked against the allowlist")]
132 NoHost,
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn a_fresh_allowlist_permits_nothing() {
141 let e = Egress::new();
142 assert!(e.permits(Some("api.anthropic.com")).is_err());
143 assert!(e.is_empty());
144 }
145
146 #[test]
147 fn a_granted_host_is_permitted() {
148 let e = Egress::new().allow("api.anthropic.com");
149 assert!(e.permits(Some("api.anthropic.com")).is_ok());
150 }
151
152 #[test]
153 fn an_ungranted_host_is_refused_and_named() {
154 let e = Egress::new().allow("api.anthropic.com");
155 let err = e.permits(Some("evil.example")).expect_err("not granted");
156 assert_eq!(
157 err,
158 EgressError::NotGranted {
159 host: "evil.example".to_owned()
160 }
161 );
162 assert!(err.to_string().contains("evil.example"));
163 }
164
165 /// DNS is case-insensitive; an allowlist that is not can be walked past.
166 #[test]
167 fn matching_is_case_insensitive() {
168 let e = Egress::new().allow("API.Anthropic.COM");
169 assert!(e.permits(Some("api.anthropic.com")).is_ok());
170 assert!(e.permits(Some("API.ANTHROPIC.COM")).is_ok());
171 }
172
173 /// A grant is exactly one host. No wildcards, no suffix matching.
174 #[test]
175 fn a_grant_does_not_extend_to_subdomains() {
176 let e = Egress::new().allow("example.com");
177 assert!(e.permits(Some("evil.example.com")).is_err());
178 assert!(e.permits(Some("example.com.evil.test")).is_err());
179 assert!(
180 e.permits(Some("notexample.com")).is_err(),
181 "a suffix comparison would have let this through"
182 );
183 }
184
185 /// Unparseable means unpermitted.
186 #[test]
187 fn a_destination_with_no_host_is_refused() {
188 let e = Egress::new().allow("example.com");
189 assert_eq!(e.permits(None), Err(EgressError::NoHost));
190 }
191
192 /// **Every refusal is a whole sentence, so no caller has to splice one.**
193 ///
194 /// This type is wrapped by three error vocabularies — a tool's, a peer
195 /// discovery's, a model driver's — and each of them once joined its own
196 /// clause to this one and produced something ungrammatical: *…the transport
197 /// for server 'ledger' reaches 'evil.example' is not a granted destination*,
198 /// and *this client may not connect to ''evil.example' is not a granted
199 /// destination…'*. The message an operator reads at 03:00 is the product
200 /// this crate ships as much as the refusal is.
201 ///
202 /// A caller cannot be stopped from splicing by a test here. What can be
203 /// pinned is the premise the callers rely on: the sentence stands alone, so
204 /// the fix is always to give it its own clause rather than to reword it.
205 #[test]
206 fn a_refusal_is_a_sentence_that_stands_on_its_own() {
207 for e in [
208 Egress::new().permits(Some("evil.example")).unwrap_err(),
209 Egress::new().permits(None).unwrap_err(),
210 ] {
211 let text = e.to_string();
212 assert!(
213 !text.ends_with(' ') && !text.starts_with(' '),
214 "a wrapped message must not depend on the caller's spacing: {text:?}"
215 );
216 // The tell of a fragment: it opens with a verb or a connective, so
217 // whatever precedes it has to supply the subject.
218 for fragment in ["is ", "was ", "which ", "that ", "and ", "because "] {
219 assert!(
220 !text.starts_with(fragment),
221 "the message opens with `{fragment}` and is therefore a \
222 fragment — a caller must splice it into a sentence of its \
223 own to read at all: {text:?}"
224 );
225 }
226 }
227 }
228
229 #[test]
230 fn grants_are_listable_for_an_operator() {
231 let e = Egress::new().allow("b.example").allow("a.example");
232 assert_eq!(
233 e.hosts().collect::<Vec<_>>(),
234 vec!["a.example", "b.example"]
235 );
236 assert_eq!(e.len(), 2);
237 }
238}