1111//! enforcement eBPF (LSM / TC) reads inline — eBPF can't do a userspace round-trip per
1212//! syscall. Default is fail-open ([`AllowAll`]): never block unless a policy opts in.
1313
14- use crate :: traits:: Identity ;
14+ use crate :: traits:: { Identity , Provider , ServiceClassifier , SniClassifier } ;
1515use std:: net:: { IpAddr , Ipv4Addr } ;
1616
1717/// Parse an egress-policy file body — the external interface's input contract. One entry per
@@ -62,20 +62,61 @@ pub trait Policy: Send + Sync {
6262pub struct AllowAll ;
6363impl Policy for AllowAll { }
6464
65- #[ cfg( test) ]
66- mod tests {
67- use super :: * ;
65+ /// An egress allow-list **by LLM provider** — observer's side of agentfw's "keep the agent on
66+ /// approved models, off the unapproved API relay / supply chain". Each outbound connection is
67+ /// classified by a [`ServiceClassifier`] (SNI → [`Provider`]) and **denied unless its provider is on
68+ /// the allow-list**; observer's `connect4`/cgroup guard enforces the deny in-kernel. Egress-only —
69+ /// file/exec stay fail-open. This is the in-process counterpart to driving the egress deny-file from
70+ /// an external controller (e.g. a3s-sentry), and the proactive complement to sentry's *reactive*
71+ /// per-destination denies: only approved providers are ever reachable in the first place.
72+ pub struct ProviderPolicy < C : ServiceClassifier = SniClassifier > {
73+ classifier : C ,
74+ allowed : Vec < Provider > ,
75+ deny_unclassified : bool ,
76+ }
77+
78+ impl ProviderPolicy < SniClassifier > {
79+ /// Allow-list these providers, classifying egress with the default [`SniClassifier`].
80+ pub fn new ( allowed : impl IntoIterator < Item = Provider > ) -> Self {
81+ Self :: with_classifier ( SniClassifier , allowed)
82+ }
83+ }
84+
85+ impl < C : ServiceClassifier > ProviderPolicy < C > {
86+ /// Allow-list these providers, classifying with a custom [`ServiceClassifier`].
87+ pub fn with_classifier ( classifier : C , allowed : impl IntoIterator < Item = Provider > ) -> Self {
88+ Self {
89+ classifier,
90+ allowed : allowed. into_iter ( ) . collect ( ) ,
91+ deny_unclassified : false ,
92+ }
93+ }
94+
95+ /// Also deny egress that matches **no** known provider — a strict "approved LLM providers only"
96+ /// cage. Default off, so unknown destinations (package mirrors, telemetry, your own APIs) still
97+ /// pass; turn on when the agent should reach nothing but its allow-listed models.
98+ pub fn deny_unclassified ( mut self , yes : bool ) -> Self {
99+ self . deny_unclassified = yes;
100+ self
101+ }
102+ }
68103
69- // A sample external policy: an egress allowlist (fail-closed for egress only).
70- struct ProviderAllowlist ( & ' static [ & ' static str ] ) ;
71- impl Policy for ProviderAllowlist {
72- fn egress ( & self , _id : & Identity , sni : Option < & str > , _peer : IpAddr ) -> Verdict {
73- match sni {
74- Some ( h) if self . 0 . iter ( ) . any ( |a| h. ends_with ( a) ) => Verdict :: Allow ,
75- _ => Verdict :: Deny ,
76- }
104+ impl < C : ServiceClassifier > Policy for ProviderPolicy < C > {
105+ fn egress ( & self , _id : & Identity , sni : Option < & str > , peer : IpAddr ) -> Verdict {
106+ match self . classifier . classify ( sni, peer) {
107+ // A known provider — allow iff it's on the list.
108+ Some ( p) if self . allowed . contains ( & p) => Verdict :: Allow ,
109+ Some ( _) => Verdict :: Deny ,
110+ // Unclassified destination — gate per the strict-cage knob.
111+ None if self . deny_unclassified => Verdict :: Deny ,
112+ None => Verdict :: Allow ,
77113 }
78114 }
115+ }
116+
117+ #[ cfg( test) ]
118+ mod tests {
119+ use super :: * ;
79120
80121 #[ test]
81122 fn allow_all_never_blocks ( ) {
@@ -86,16 +127,31 @@ mod tests {
86127 }
87128
88129 #[ test]
89- fn external_allowlist_gates_egress_only ( ) {
90- let p = ProviderAllowlist ( & [ "anthropic.com" , "openai.com" ] ) ;
130+ fn provider_policy_allows_listed_denies_other_providers ( ) {
131+ let p = ProviderPolicy :: new ( [ Provider :: Anthropic , Provider :: OpenAi ] ) ;
91132 let id = Identity :: default ( ) ;
92133 let ip = IpAddr :: from ( [ 0 , 0 , 0 , 0 ] ) ;
134+ // allow-listed provider → allow
93135 assert_eq ! ( p. egress( & id, Some ( "api.anthropic.com" ) , ip) , Verdict :: Allow ) ;
94- assert_eq ! ( p. egress( & id, Some ( "evil.example.com" ) , ip) , Verdict :: Deny ) ;
95- // non-egress actions still default to allow
136+ // a *known* provider not on the list → deny (an unapproved model/relay)
137+ assert_eq ! ( p. egress( & id, Some ( "api.deepseek.com" ) , ip) , Verdict :: Deny ) ;
138+ // unclassified destination → allow by default (don't cut non-LLM traffic)
139+ assert_eq ! ( p. egress( & id, Some ( "github.com" ) , ip) , Verdict :: Allow ) ;
140+ // egress-only: file/exec still fail-open
96141 assert_eq ! ( p. file_write( & id, "/tmp/x" ) , Verdict :: Allow ) ;
97142 }
98143
144+ #[ test]
145+ fn provider_policy_strict_cage_denies_unclassified ( ) {
146+ let p = ProviderPolicy :: new ( [ Provider :: Anthropic ] ) . deny_unclassified ( true ) ;
147+ let id = Identity :: default ( ) ;
148+ let ip = IpAddr :: from ( [ 0 , 0 , 0 , 0 ] ) ;
149+ assert_eq ! ( p. egress( & id, Some ( "api.anthropic.com" ) , ip) , Verdict :: Allow ) ;
150+ // strict cage: anything that isn't an approved provider — incl. unknown hosts — is denied
151+ assert_eq ! ( p. egress( & id, Some ( "github.com" ) , ip) , Verdict :: Deny ) ;
152+ assert_eq ! ( p. egress( & id, None , ip) , Verdict :: Deny ) ;
153+ }
154+
99155 #[ test]
100156 fn parses_egress_policy_file ( ) {
101157 let ( ips, hosts) =
0 commit comments