Skip to main content

agent_sdk_toolkit/environment/
egress.rs

1use agent_sdk_core::{AgentError, NetworkIsolationPolicy};
2use serde::{Deserialize, Serialize};
3
4#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5#[serde(rename_all = "snake_case")]
6/// Transport/application hint for an environment egress target.
7/// Selecting a protocol is data-only; concrete enforcement belongs to the
8/// registered isolation runtime adapter.
9pub enum EgressProtocol {
10    /// HTTPS-style egress, defaulting to port 443.
11    Https,
12    /// HTTP-style egress, defaulting to port 80.
13    Http,
14    /// TCP egress, requiring a concrete port.
15    Tcp,
16    /// UDP egress, requiring a concrete port.
17    Udp,
18}
19
20impl EgressProtocol {
21    fn from_scheme(scheme: &str) -> Option<Self> {
22        match scheme {
23            "https" => Some(Self::Https),
24            "http" => Some(Self::Http),
25            "tcp" => Some(Self::Tcp),
26            "udp" => Some(Self::Udp),
27            _ => None,
28        }
29    }
30
31    fn default_port(self) -> Option<u16> {
32        match self {
33            Self::Https => Some(443),
34            Self::Http => Some(80),
35            Self::Tcp | Self::Udp => None,
36        }
37    }
38
39    fn as_str(self) -> &'static str {
40        match self {
41            Self::Https => "https",
42            Self::Http => "http",
43            Self::Tcp => "tcp",
44            Self::Udp => "udp",
45        }
46    }
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
50/// One canonical egress target requested for an isolated environment.
51/// The value is policy intent, not proof that a runtime has enforced it.
52pub struct EgressTarget {
53    /// Domain or host alias requested by policy.
54    pub host: String,
55    /// Destination port requested by policy.
56    pub port: u16,
57    /// Protocol hint for adapter translation.
58    pub protocol: EgressProtocol,
59}
60
61impl EgressTarget {
62    /// Parses and canonicalizes an egress target.
63    ///
64    /// Accepted forms include `example.com`, `example.com:443`,
65    /// `https://example.com`, `http://example.com:80`,
66    /// `tcp://example.com:443`, and `udp://resolver.example:53`.
67    /// URL paths, credentials, query strings, fragments, wildcards, empty hosts,
68    /// and invalid ports are rejected because this value describes an egress
69    /// boundary rather than a fetch request.
70    pub fn parse(input: impl AsRef<str>) -> Result<Self, AgentError> {
71        let input = input.as_ref().trim();
72        if input.is_empty() {
73            return Err(invalid_egress_target("egress target is empty"));
74        }
75        if input.chars().any(char::is_whitespace) {
76            return Err(invalid_egress_target(
77                "egress target must not contain whitespace",
78            ));
79        }
80
81        let (protocol, authority) = if let Some((scheme, rest)) = input.split_once("://") {
82            let scheme = scheme.to_ascii_lowercase();
83            let protocol = EgressProtocol::from_scheme(&scheme).ok_or_else(|| {
84                invalid_egress_target(format!("unsupported egress protocol: {scheme}"))
85            })?;
86            (protocol, rest)
87        } else {
88            (EgressProtocol::Https, input)
89        };
90
91        if authority
92            .chars()
93            .any(|ch| matches!(ch, '/' | '?' | '#' | '@' | '\\'))
94        {
95            return Err(invalid_egress_target(
96                "egress target must be a host or host:port, not a URL path or credential",
97            ));
98        }
99
100        let (host, port) = parse_authority(authority, protocol)?;
101        validate_host(&host)?;
102        Ok(Self {
103            host: host.to_ascii_lowercase(),
104            port,
105            protocol,
106        })
107    }
108
109    /// Returns the canonical string lowered into `NetworkIsolationPolicy`.
110    pub fn canonical(&self) -> String {
111        format!("{}://{}:{}", self.protocol.as_str(), self.host, self.port)
112    }
113}
114
115#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
116/// Data-only builder for deterministic egress allowlists.
117pub struct EgressAllowlist {
118    entries: Vec<String>,
119}
120
121impl EgressAllowlist {
122    /// Creates an empty allowlist.
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Adds one allowlist target for validation during lowering.
128    pub fn allow(mut self, target: impl Into<String>) -> Self {
129        self.entries.push(target.into());
130        self
131    }
132
133    /// Adds and validates one allowlist target immediately.
134    pub fn try_allow(mut self, target: impl AsRef<str>) -> Result<Self, AgentError> {
135        self.entries.push(EgressTarget::parse(target)?.canonical());
136        Ok(self)
137    }
138
139    /// Builds an allowlist from multiple targets.
140    pub fn from_targets(targets: impl IntoIterator<Item = impl Into<String>>) -> Self {
141        let mut allowlist = Self::new();
142        for target in targets {
143            allowlist = allowlist.allow(target);
144        }
145        allowlist
146    }
147
148    /// Builds and validates an allowlist from multiple targets.
149    pub fn try_from_targets(
150        targets: impl IntoIterator<Item = impl AsRef<str>>,
151    ) -> Result<Self, AgentError> {
152        let mut allowlist = Self::new();
153        for target in targets {
154            allowlist = allowlist.try_allow(target)?;
155        }
156        Ok(allowlist)
157    }
158
159    /// Returns canonical targets in deterministic order.
160    pub fn targets(&self) -> Result<Vec<EgressTarget>, AgentError> {
161        self.parsed_targets()
162    }
163
164    /// Returns canonical egress rule entries suitable for core `NetworkIsolationPolicy`.
165    pub fn canonical_entries(&self) -> Result<Vec<String>, AgentError> {
166        Ok(self
167            .parsed_targets()?
168            .iter()
169            .map(EgressTarget::canonical)
170            .collect::<Vec<_>>())
171    }
172
173    /// Lowers this toolkit helper into the core network policy contract.
174    pub fn network_policy(&self) -> Result<NetworkIsolationPolicy, AgentError> {
175        Ok(NetworkIsolationPolicy::EgressScoped {
176            rules: self.canonical_entries()?,
177        })
178    }
179
180    /// Returns whether the allowlist has no targets.
181    pub fn is_empty(&self) -> bool {
182        self.entries.is_empty()
183    }
184
185    fn parsed_targets(&self) -> Result<Vec<EgressTarget>, AgentError> {
186        let mut targets = self
187            .entries
188            .iter()
189            .map(EgressTarget::parse)
190            .collect::<Result<Vec<_>, _>>()?;
191        targets.sort();
192        targets.dedup();
193        Ok(targets)
194    }
195}
196
197fn parse_authority(authority: &str, protocol: EgressProtocol) -> Result<(String, u16), AgentError> {
198    if authority.is_empty() {
199        return Err(invalid_egress_target("egress target host is empty"));
200    }
201    if authority.matches(':').count() > 1 {
202        return Err(invalid_egress_target(
203            "egress target must not use raw IPv6 or multiple port separators",
204        ));
205    }
206    if let Some((host, port)) = authority.rsplit_once(':') {
207        if host.is_empty() {
208            return Err(invalid_egress_target("egress target host is empty"));
209        }
210        let port = port
211            .parse::<u16>()
212            .map_err(|_| invalid_egress_target("egress target port must be 1..65535"))?;
213        if port == 0 {
214            return Err(invalid_egress_target("egress target port must be nonzero"));
215        }
216        Ok((host.to_string(), port))
217    } else if let Some(port) = protocol.default_port() {
218        Ok((authority.to_string(), port))
219    } else {
220        Err(invalid_egress_target(
221            "tcp and udp egress targets must include an explicit port",
222        ))
223    }
224}
225
226fn validate_host(host: &str) -> Result<(), AgentError> {
227    if host.is_empty() {
228        return Err(invalid_egress_target("egress target host is empty"));
229    }
230    if host == "*" || host.contains('*') {
231        return Err(invalid_egress_target(
232            "egress target host wildcards are not supported",
233        ));
234    }
235    if host.starts_with('.') || host.ends_with('.') {
236        return Err(invalid_egress_target(
237            "egress target host must not start or end with '.'",
238        ));
239    }
240    for label in host.split('.') {
241        if label.is_empty() {
242            return Err(invalid_egress_target(
243                "egress target host must not contain empty labels",
244            ));
245        }
246        if label.starts_with('-') || label.ends_with('-') {
247            return Err(invalid_egress_target(
248                "egress target host labels must not start or end with '-'",
249            ));
250        }
251        if !label
252            .chars()
253            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
254        {
255            return Err(invalid_egress_target(
256                "egress target host labels must be ASCII alphanumeric or '-'",
257            ));
258        }
259    }
260    Ok(())
261}
262
263fn invalid_egress_target(message: impl Into<String>) -> AgentError {
264    AgentError::contract_violation(message.into())
265}