Skip to main content

aa_core/
policy.rs

1//! Policy types and the [`PolicyEvaluator`] trait for governance decisions.
2//!
3//! A [`GovernanceAction`] describes what an agent wants to do.
4//! A [`PolicyEvaluator`] decides whether that action is permitted,
5//! denied, or requires human approval, and returns a [`PolicyResult`].
6//! Policy rules are expressed as [`PolicyDocument`] objects containing
7//! ordered [`PolicyRule`] entries.
8
9/// Pre-serialized JSON string passed at policy trait boundaries.
10///
11/// Callers serialize arguments before handing them to an evaluator;
12/// evaluators deserialize lazily only if they need to inspect the payload.
13/// This keeps the trait boundary free of any serde-json dependency.
14#[cfg(feature = "alloc")]
15pub type ArgsJson = alloc::string::String;
16
17/// File access mode for `GovernanceAction::FileAccess`.
18#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
20pub enum FileMode {
21    /// Open the file for reading only.
22    Read,
23    /// Open the file for writing, truncating any existing content.
24    Write,
25    /// Open the file for writing, appending to existing content.
26    Append,
27    /// Delete the file from the filesystem.
28    Delete,
29}
30
31/// Errors produced during policy loading or evaluation.
32///
33/// All variants are heap-free so `PolicyError` can be used in bare `no_std`
34/// contexts that have no `alloc`.
35#[derive(Debug, Clone, PartialEq)]
36pub enum PolicyError {
37    /// The supplied `PolicyDocument` is structurally invalid.
38    InvalidDocument,
39    /// The `GovernanceAction` variant is not recognized by this evaluator.
40    UnknownAction,
41    /// The evaluator encountered an internal error during evaluation.
42    EvaluationFailed,
43}
44
45/// The decision recorded in a `PolicyRule`.
46#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub enum PolicyDecision {
49    /// The action is permitted without restriction.
50    Allow,
51    /// The action is prohibited.
52    Deny,
53    /// The action may proceed only after explicit human approval.
54    RequireApproval,
55}
56
57/// Controls whether policy decisions are applied to agent actions or only observed.
58///
59/// Mirrors the proto `EnforcementMode` enum defined in `proto/policy.proto` so
60/// pure-Rust code can reason about the enforcement posture without a proto
61/// dependency.
62///
63/// | Mode       | Proto value | Effect on `Deny` / `Redact` / `Pending` / `BudgetBlock` |
64/// |------------|-------------|---------------------------------------------------------|
65/// | `Enforce`  | 1           | Decision applied; agent blocked / payload redacted.     |
66/// | `Observe`  | 2           | Decision recorded as a shadow audit event; agent proceeds. |
67/// | `Disabled` | 3           | Policy evaluation skipped entirely (test environments). |
68///
69/// `Enforce` is the default — omitting `enforcement_mode` from any
70/// policy document or registration payload leaves existing behavior unchanged.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
74pub enum EnforcementMode {
75    /// Default: deny blocks, redact strips, pending halts execution.
76    #[default]
77    Enforce,
78    /// Dry-run / sandbox: decisions computed and audited; no enforcement applied.
79    Observe,
80    /// Policy evaluation disabled entirely. Only valid in hermetic test environments.
81    Disabled,
82}
83
84impl EnforcementMode {
85    /// Convert from the proto integer value (1=Enforce … 3=Disabled).
86    ///
87    /// Returns `None` for 0 (UNSPECIFIED) and any out-of-range value so callers
88    /// can fall back to a server-side default rather than silently coercing.
89    pub fn from_proto_i32(v: i32) -> Option<Self> {
90        match v {
91            1 => Some(Self::Enforce),
92            2 => Some(Self::Observe),
93            3 => Some(Self::Disabled),
94            _ => None,
95        }
96    }
97}
98
99/// A single rule inside a `PolicyDocument`.
100///
101/// Gated on `alloc` because `action_pattern` is a `String`.
102#[cfg(feature = "alloc")]
103#[derive(Debug, Clone, PartialEq)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub struct PolicyRule {
106    /// Glob-style pattern matched against the action name or path.
107    pub action_pattern: alloc::string::String,
108    /// Decision to apply when the pattern matches.
109    pub decision: PolicyDecision,
110}
111
112/// Minimal policy document stub.
113///
114/// Full schema deferred to AAASM-105/AAASM-69. Sufficient for test evaluators
115/// to implement `load_policy` and `validate_policy` without a real parser.
116///
117/// Gated on `alloc` because `name` and `rules` require heap allocation.
118#[cfg(feature = "alloc")]
119#[derive(Debug, Clone, PartialEq)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct PolicyDocument {
122    /// Schema version number.
123    pub version: u32,
124    /// Human-readable policy name.
125    pub name: alloc::string::String,
126    /// Ordered list of rules evaluated top-to-bottom.
127    pub rules: alloc::vec::Vec<PolicyRule>,
128    /// Enforcement posture for this policy. Defaults to `Enforce` when the
129    /// field is absent from the source document, preserving pre-feature
130    /// behavior for all existing policies.
131    #[cfg_attr(feature = "serde", serde(default))]
132    pub enforcement_mode: EnforcementMode,
133}
134
135/// The outcome of a `PolicyEvaluator::evaluate` call.
136///
137/// Gated on `alloc` because `Deny::reason` carries a `String`.
138#[cfg(feature = "alloc")]
139#[derive(Debug, Clone, PartialEq)]
140#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
141pub enum PolicyResult {
142    /// The action is permitted.
143    Allow,
144    /// The action is denied; `reason` explains why.
145    Deny {
146        /// Human-readable description of why the action was denied.
147        reason: alloc::string::String,
148    },
149    /// Human approval is required within the given timeout.
150    RequiresApproval {
151        /// Maximum seconds to wait for human approval before the request expires.
152        timeout_secs: u32,
153    },
154}
155
156/// An agent action subject to governance evaluation.
157///
158/// Gated on `alloc` because all variants carry `String` fields.
159#[cfg(feature = "alloc")]
160#[derive(Debug, Clone, PartialEq)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub enum GovernanceAction {
163    /// Invocation of a named tool with pre-serialized JSON arguments.
164    ToolCall {
165        /// Registered name of the tool being invoked.
166        name: alloc::string::String,
167        /// Pre-serialized JSON arguments passed to the tool.
168        args: ArgsJson,
169    },
170    /// Result returned by a tool invocation, evaluated on the response path
171    /// before the result is forwarded back to the agent.
172    ///
173    /// Carries the same shape as `ToolCall.args` — a pre-serialized JSON
174    /// string — so the policy engine can apply JSON-pointer-addressed
175    /// predicates (e.g. `tool_result.foo`) and credential-pattern scans
176    /// against the body the upstream tool emitted.
177    ToolResult {
178        /// Registered name of the tool that produced the result.
179        tool_name: alloc::string::String,
180        /// Pre-serialized JSON body of the tool's response.
181        result: ArgsJson,
182    },
183    /// Read or write access to a file path.
184    FileAccess {
185        /// Absolute or relative path of the file being accessed.
186        path: alloc::string::String,
187        /// Access mode (read, write, append, or delete).
188        mode: FileMode,
189    },
190    /// Outbound network request.
191    NetworkRequest {
192        /// Target URL of the outbound request.
193        url: alloc::string::String,
194        /// HTTP method (e.g., `"GET"`, `"POST"`).
195        method: alloc::string::String,
196    },
197    /// Spawning an external process.
198    ProcessExec {
199        /// Full shell command string to be executed.
200        command: alloc::string::String,
201    },
202    /// Inter-team message sent through a named channel.
203    SendMessage {
204        /// Team ID of the sending agent's team. `None` when the sender has no team.
205        source_team_id: Option<alloc::string::String>,
206        /// Team ID of the intended recipient team. `None` when the target is unresolved.
207        target_team_id: Option<alloc::string::String>,
208        /// Logical channel identifier through which the message is routed.
209        channel_id: Option<alloc::string::String>,
210    },
211}
212
213/// Pluggable policy evaluation backend.
214///
215/// Implementors decide whether a given `GovernanceAction` is permitted for
216/// a given `AgentContext`. The trait is object-safe: `dyn PolicyEvaluator`
217/// is valid because no method has generic parameters or returns `Self`.
218///
219/// Gated on `alloc` because `GovernanceAction` and `PolicyDocument` require it.
220#[cfg(feature = "alloc")]
221pub trait PolicyEvaluator {
222    /// Evaluate whether `action` is permitted for `ctx`.
223    fn evaluate(&self, ctx: &crate::AgentContext, action: &GovernanceAction) -> PolicyResult;
224
225    /// Load a policy document into this evaluator, replacing any prior policy.
226    ///
227    /// Requires `&mut self`, so callers holding `&dyn PolicyEvaluator` must
228    /// upgrade to `&mut dyn PolicyEvaluator` before calling this method.
229    fn load_policy(&mut self, policy: &PolicyDocument) -> Result<(), PolicyError>;
230
231    /// Validate a policy document without applying it.
232    ///
233    /// Returns all validation errors found, or `Ok(())` if the document is valid.
234    fn validate_policy(&self, policy: &PolicyDocument) -> Result<(), alloc::vec::Vec<PolicyError>>;
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn file_mode_clone_and_eq() {
243        let m = FileMode::Read;
244        assert_eq!(m.clone(), FileMode::Read);
245        assert_ne!(FileMode::Write, FileMode::Delete);
246    }
247
248    #[test]
249    fn file_mode_all_variants() {
250        // Verify all variants are constructible and distinct.
251        assert_ne!(FileMode::Read, FileMode::Write);
252        assert_ne!(FileMode::Append, FileMode::Delete);
253        assert_ne!(FileMode::Write, FileMode::Append);
254    }
255
256    #[test]
257    #[cfg(feature = "alloc")]
258    fn governance_action_tool_call() {
259        let action = GovernanceAction::ToolCall {
260            name: alloc::string::String::from("list_files"),
261            args: alloc::string::String::from("{\"dir\":\"/tmp\"}"),
262        };
263        assert_eq!(action.clone(), action);
264    }
265
266    #[test]
267    #[cfg(feature = "alloc")]
268    fn governance_action_tool_result() {
269        let action = GovernanceAction::ToolResult {
270            tool_name: alloc::string::String::from("list_files"),
271            result: alloc::string::String::from("{\"entries\":[\"a.txt\"]}"),
272        };
273        assert_eq!(action.clone(), action);
274    }
275
276    #[test]
277    #[cfg(all(feature = "alloc", feature = "serde"))]
278    fn governance_action_tool_result_serde_round_trip() {
279        // The audit pipeline serialises every GovernanceAction it sees; if the
280        // new variant fails to round-trip through serde, downstream audit
281        // entries silently lose response-side actions.
282        let action = GovernanceAction::ToolResult {
283            tool_name: alloc::string::String::from("read_file"),
284            result: alloc::string::String::from("{\"contents\":\"sk-test-abc\"}"),
285        };
286        let encoded = serde_json::to_string(&action).expect("serialize");
287        let decoded: GovernanceAction = serde_json::from_str(&encoded).expect("deserialize");
288        assert_eq!(decoded, action);
289    }
290
291    #[test]
292    #[cfg(feature = "alloc")]
293    fn governance_action_file_access() {
294        let action = GovernanceAction::FileAccess {
295            path: alloc::string::String::from("/etc/passwd"),
296            mode: FileMode::Read,
297        };
298        let cloned = action.clone();
299        assert_eq!(action, cloned);
300    }
301
302    #[test]
303    #[cfg(feature = "alloc")]
304    fn governance_action_network_request() {
305        let action = GovernanceAction::NetworkRequest {
306            url: alloc::string::String::from("https://example.com"),
307            method: alloc::string::String::from("GET"),
308        };
309        assert_eq!(action.clone(), action);
310    }
311
312    #[test]
313    #[cfg(feature = "alloc")]
314    fn governance_action_spawn() {
315        let action = GovernanceAction::ProcessExec {
316            command: alloc::string::String::from("ls -la"),
317        };
318        assert_eq!(action.clone(), action);
319    }
320
321    #[test]
322    #[cfg(feature = "alloc")]
323    fn policy_result_allow() {
324        assert_eq!(PolicyResult::Allow, PolicyResult::Allow);
325        assert_eq!(PolicyResult::Allow.clone(), PolicyResult::Allow);
326    }
327
328    #[test]
329    #[cfg(feature = "alloc")]
330    fn policy_result_deny_reason() {
331        let r = PolicyResult::Deny {
332            reason: alloc::string::String::from("blocked"),
333        };
334        if let PolicyResult::Deny { reason } = &r {
335            assert_eq!(reason, "blocked");
336        } else {
337            panic!("expected Deny");
338        }
339    }
340
341    #[test]
342    #[cfg(feature = "alloc")]
343    fn policy_result_requires_approval() {
344        let r = PolicyResult::RequiresApproval { timeout_secs: 30 };
345        if let PolicyResult::RequiresApproval { timeout_secs } = r {
346            assert_eq!(timeout_secs, 30);
347        } else {
348            panic!("expected RequiresApproval");
349        }
350    }
351
352    #[test]
353    fn policy_error_variants() {
354        assert_eq!(PolicyError::InvalidDocument, PolicyError::InvalidDocument);
355        assert_ne!(PolicyError::UnknownAction, PolicyError::EvaluationFailed);
356    }
357
358    #[test]
359    fn policy_decision_variants() {
360        assert_eq!(PolicyDecision::Allow, PolicyDecision::Allow);
361        assert_ne!(PolicyDecision::Deny, PolicyDecision::RequireApproval);
362    }
363
364    #[test]
365    fn enforcement_mode_default_is_enforce() {
366        // Pre-feature semantics: omitting the mode anywhere must resolve to Enforce.
367        assert_eq!(EnforcementMode::default(), EnforcementMode::Enforce);
368    }
369
370    #[test]
371    fn enforcement_mode_from_proto_i32_round_trips_known_values() {
372        // The proto reserves 0 for UNSPECIFIED — it must NOT silently coerce
373        // to Enforce here; only valid 1/2/3 produce Some(_). Server-side
374        // resolution is responsible for picking a default for unspecified.
375        assert_eq!(EnforcementMode::from_proto_i32(1), Some(EnforcementMode::Enforce));
376        assert_eq!(EnforcementMode::from_proto_i32(2), Some(EnforcementMode::Observe));
377        assert_eq!(EnforcementMode::from_proto_i32(3), Some(EnforcementMode::Disabled));
378        assert_eq!(EnforcementMode::from_proto_i32(0), None);
379        assert_eq!(EnforcementMode::from_proto_i32(-1), None);
380        assert_eq!(EnforcementMode::from_proto_i32(99), None);
381    }
382
383    #[cfg(feature = "serde")]
384    #[test]
385    fn enforcement_mode_serde_snake_case_round_trip() {
386        // The wire / YAML representation must use lowercase tokens — operators
387        // type `enforcement_mode: observe`, not `Observe`.
388        for (mode, expected) in [
389            (EnforcementMode::Enforce, "\"enforce\""),
390            (EnforcementMode::Observe, "\"observe\""),
391            (EnforcementMode::Disabled, "\"disabled\""),
392        ] {
393            let json = serde_json::to_string(&mode).unwrap();
394            assert_eq!(json, expected, "{mode:?} must serialise as {expected}");
395            let back: EnforcementMode = serde_json::from_str(&json).unwrap();
396            assert_eq!(back, mode, "{expected} must deserialise back to {mode:?}");
397        }
398    }
399
400    #[test]
401    #[cfg(feature = "alloc")]
402    fn policy_rule_field_access_clone_eq() {
403        let rule = PolicyRule {
404            action_pattern: alloc::string::String::from("tool_call/*"),
405            decision: PolicyDecision::Deny,
406        };
407        let cloned = rule.clone();
408        assert_eq!(rule, cloned);
409        assert_eq!(rule.action_pattern, "tool_call/*");
410        assert_eq!(rule.decision, PolicyDecision::Deny);
411    }
412
413    #[test]
414    #[cfg(feature = "alloc")]
415    fn policy_document_field_access_clone_eq() {
416        let doc = PolicyDocument {
417            version: 1,
418            name: alloc::string::String::from("test-policy"),
419            rules: alloc::vec![PolicyRule {
420                action_pattern: alloc::string::String::from("*"),
421                decision: PolicyDecision::Allow,
422            }],
423            enforcement_mode: EnforcementMode::default(),
424        };
425        let cloned = doc.clone();
426        assert_eq!(doc, cloned);
427        assert_eq!(doc.version, 1);
428        assert_eq!(doc.name, "test-policy");
429        assert_eq!(doc.rules.len(), 1);
430        assert_eq!(doc.rules[0].decision, PolicyDecision::Allow);
431    }
432
433    #[cfg(all(feature = "alloc", feature = "serde"))]
434    #[test]
435    fn policy_document_enforcement_mode_parses_observe_from_yaml() {
436        // An operator-authored sandbox policy: `enforcement_mode: observe`
437        // at the document root must surface as EnforcementMode::Observe.
438        let yaml = "version: 1\nname: sandbox-policy\nenforcement_mode: observe\nrules: []\n";
439        let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
440        assert_eq!(doc.enforcement_mode, EnforcementMode::Observe);
441    }
442
443    #[cfg(all(feature = "alloc", feature = "serde"))]
444    #[test]
445    fn policy_document_enforcement_mode_defaults_to_enforce_when_absent() {
446        // Backward compatibility: pre-feature YAML / JSON policy documents
447        // never had this field, so deserialising one must produce Enforce.
448        let yaml = "version: 1\nname: legacy-policy\nrules: []\n";
449        let doc: PolicyDocument = serde_yaml::from_str(yaml).unwrap();
450        assert_eq!(doc.enforcement_mode, EnforcementMode::Enforce);
451    }
452
453    #[test]
454    #[cfg(feature = "alloc")]
455    fn policy_result_cross_variant_inequality() {
456        assert_ne!(
457            PolicyResult::Allow,
458            PolicyResult::Deny {
459                reason: alloc::string::String::from("x")
460            }
461        );
462        assert_ne!(
463            PolicyResult::Deny {
464                reason: alloc::string::String::from("x")
465            },
466            PolicyResult::RequiresApproval { timeout_secs: 10 }
467        );
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Egress allowlist matcher (AAASM-1943)
473// ---------------------------------------------------------------------------
474
475/// Decide whether a host is allowed by an outbound-egress allowlist.
476///
477/// Semantics:
478///
479/// * **Empty allowlist** → `true` (allowlist disabled — caller falls back to
480///   whatever default policy applies, typically Allow).
481/// * **Non-empty allowlist** → `true` only when `host` matches at least one
482///   pattern; `false` otherwise.
483///
484/// Pattern syntax (`aa-proxy` + `aa-gateway` policy DSL share this):
485///
486/// * **Exact match**: `api.openai.com` matches `api.openai.com` only.
487/// * **Leftmost-label wildcard**: `*.openai.com` matches `api.openai.com`,
488///   `chat.openai.com`, etc. but NOT `openai.com` itself and NOT
489///   `evil.example.com.openai.com.attacker.com`.
490/// * **Universal wildcard**: `*` matches every host (escape hatch for
491///   "allow everything that isn't otherwise denied"; rarely used).
492///
493/// Matching is **case-insensitive** for the host portion since DNS labels are
494/// case-insensitive (RFC 4343).
495///
496/// The pattern is intentionally narrow — we don't accept arbitrary glob
497/// (`?`, character classes, full-`*` mid-label) because allowlist patterns
498/// that look more permissive than they are have historically been the source
499/// of egress-rule misconfigurations. The narrow grammar lets operators
500/// reason about every pattern at a glance.
501#[cfg(feature = "alloc")]
502pub fn is_host_allowed_by_egress_allowlist(host: &str, allowlist: &[alloc::string::String]) -> bool {
503    if allowlist.is_empty() {
504        return true;
505    }
506    let host_lower = host.to_ascii_lowercase();
507    for pattern in allowlist {
508        if egress_pattern_matches(pattern, &host_lower) {
509            return true;
510        }
511    }
512    false
513}
514
515#[cfg(feature = "alloc")]
516fn egress_pattern_matches(pattern: &str, host_lower: &str) -> bool {
517    let pattern_lower = pattern.to_ascii_lowercase();
518    if pattern_lower == "*" {
519        return true;
520    }
521    if let Some(suffix) = pattern_lower.strip_prefix("*.") {
522        // *.example.com matches anything ending in `.example.com` AFTER at
523        // least one extra label — does NOT match the bare suffix or
524        // attacker-crafted subdomains where the suffix is not at the right.
525        let required_suffix = alloc::format!(".{suffix}");
526        return host_lower.ends_with(&required_suffix) && host_lower.len() > required_suffix.len();
527    }
528    pattern_lower == host_lower
529}
530
531#[cfg(all(test, feature = "alloc"))]
532mod egress_tests {
533    use alloc::string::ToString;
534    use alloc::vec;
535
536    use super::is_host_allowed_by_egress_allowlist;
537
538    #[test]
539    fn empty_allowlist_is_default_allow() {
540        assert!(is_host_allowed_by_egress_allowlist("api.example.com", &[]));
541        assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &[]));
542    }
543
544    #[test]
545    fn exact_match_only_matches_exact_host() {
546        let list = vec!["api.openai.com".to_string()];
547        assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
548        assert!(!is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
549        assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
550        assert!(!is_host_allowed_by_egress_allowlist("attackerapi.openai.com", &list));
551    }
552
553    #[test]
554    fn case_insensitive_host_match() {
555        let list = vec!["API.OpenAI.com".to_string()];
556        assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
557        assert!(is_host_allowed_by_egress_allowlist("API.OPENAI.COM", &list));
558    }
559
560    #[test]
561    fn leftmost_wildcard_matches_subdomain() {
562        let list = vec!["*.openai.com".to_string()];
563        assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
564        assert!(is_host_allowed_by_egress_allowlist("chat.openai.com", &list));
565        assert!(is_host_allowed_by_egress_allowlist("a.b.openai.com", &list));
566    }
567
568    #[test]
569    fn leftmost_wildcard_does_not_match_bare_suffix() {
570        let list = vec!["*.openai.com".to_string()];
571        assert!(!is_host_allowed_by_egress_allowlist("openai.com", &list));
572    }
573
574    #[test]
575    fn leftmost_wildcard_does_not_match_attacker_crafted_suffix() {
576        let list = vec!["*.openai.com".to_string()];
577        // Classic confusion attack: the attacker hopes a glob would match
578        // `evil.openai.com.attacker.net`. Our grammar refuses.
579        assert!(!is_host_allowed_by_egress_allowlist(
580            "evil.openai.com.attacker.net",
581            &list
582        ));
583    }
584
585    #[test]
586    fn universal_wildcard_matches_any_host() {
587        let list = vec!["*".to_string()];
588        assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
589        assert!(is_host_allowed_by_egress_allowlist("evil.attacker.net", &list));
590        assert!(is_host_allowed_by_egress_allowlist("anything", &list));
591    }
592
593    #[test]
594    fn multiple_patterns_any_match_allows() {
595        let list = vec!["api.openai.com".to_string(), "*.anthropic.com".to_string()];
596        assert!(is_host_allowed_by_egress_allowlist("api.openai.com", &list));
597        assert!(is_host_allowed_by_egress_allowlist("api.anthropic.com", &list));
598        assert!(!is_host_allowed_by_egress_allowlist("api.cohere.com", &list));
599    }
600}