agent_sdk_toolkit/environment/
egress.rs1use 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")]
6pub enum EgressProtocol {
10 Https,
12 Http,
14 Tcp,
16 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)]
50pub struct EgressTarget {
53 pub host: String,
55 pub port: u16,
57 pub protocol: EgressProtocol,
59}
60
61impl EgressTarget {
62 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 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)]
116pub struct EgressAllowlist {
118 entries: Vec<String>,
119}
120
121impl EgressAllowlist {
122 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn allow(mut self, target: impl Into<String>) -> Self {
129 self.entries.push(target.into());
130 self
131 }
132
133 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 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 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 pub fn targets(&self) -> Result<Vec<EgressTarget>, AgentError> {
161 self.parsed_targets()
162 }
163
164 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 pub fn network_policy(&self) -> Result<NetworkIsolationPolicy, AgentError> {
175 Ok(NetworkIsolationPolicy::EgressScoped {
176 rules: self.canonical_entries()?,
177 })
178 }
179
180 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}