Skip to content

Commit b1deb73

Browse files
ZhiXiao-LinRoyLin
andauthored
feat(policy): ship ProviderPolicy — LLM-provider egress allow-list (#5)
A built-in Policy that allow-lists egress by LLM provider (SNI-classified) and denies the rest, enforced in-kernel by the connect4/cgroup guard — observer's side of agentfw's "keep the agent on approved models / off the unapproved API relay & supply chain". deny_unclassified for a strict cage; egress-only; host-buildable (no eBPF change). Proactive complement to a3s-sentry's reactive per-destination denies. Co-authored-by: RoyLin <roylin@RoyLindeMacBook-Pro.local>
1 parent 579e77a commit b1deb73

3 files changed

Lines changed: 96 additions & 17 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ each reload); **file/exec** = one path per line. Prefer in-process (Rust) embedd
129129
the `Policy` trait (`egress` / `file_write` / `exec``Verdict`). Full design + both paths:
130130
[`docs/enforcement.md`](docs/enforcement.md).
131131

132+
**Built-in `ProviderPolicy`** — a shipped `Policy` that allow-lists egress **by LLM provider**
133+
(classified from SNI by the default `SniClassifier`, or any `ServiceClassifier` via
134+
`.with_classifier(classifier, allowed)`) and **denies any connection whose provider isn't on the
135+
list** — the `connect4`/cgroup guard enforces the deny in-kernel. observer's side of "keep the
136+
agent on approved models, off the unapproved API relay / supply chain". **Egress-only**
137+
file/exec stay fail-open. It's the proactive complement to
138+
[a3s-sentry](https://github.com/A3S-Lab/Sentry)'s *reactive* per-destination denies (only approved
139+
providers are ever reachable in the first place), and **host-buildable** — it adds no eBPF, the
140+
core is untouched:
141+
142+
```rust
143+
use a3s_observer::{Provider, ProviderPolicy};
144+
145+
// Default: only a *known, non-approved* provider is denied; unknown destinations
146+
// (package mirrors, telemetry, your own APIs) still pass — deny_unclassified is false.
147+
let policy = ProviderPolicy::new([Provider::Anthropic, Provider::OpenAi]);
148+
// api.anthropic.com → Allow · api.deepseek.com → Deny (known provider, not approved) · github.com → Allow
149+
150+
// Strict "approved providers only" cage: anything that isn't allow-listed — incl. unknown hosts — is denied.
151+
let cage = ProviderPolicy::new([Provider::Anthropic]).deny_unclassified(true);
152+
// api.anthropic.com → Allow · github.com → Deny · no-SNI → Deny
153+
```
154+
132155
## Why eBPF, and the boundary
133156

134157
- **Zero-instrumentation, language-agnostic** — observe or guard any agent (Python/Node/Go/Rust)

src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub mod policy;
1818
pub mod traits;
1919

2020
pub use model::{AgentEvent, EnrichedEvent};
21-
pub use policy::{parse_egress_policy, AllowAll, Policy, Verdict};
21+
pub use policy::{parse_egress_policy, AllowAll, Policy, ProviderPolicy, Verdict};
2222
pub use traits::{
2323
read_ppid, Exporter, Identity, IdentityResolver, JsonExporter, KubeResolver, LogExporter,
2424
ProcResolver, Provider, ServiceClassifier, SniClassifier,

src/policy.rs

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
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};
1515
use 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 {
6262
pub struct AllowAll;
6363
impl 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

Comments
 (0)