Skip to main content

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}