From 4f4568c556a4307e5f7d151f560a1bf29d465e84 Mon Sep 17 00:00:00 2001 From: RoyLin Date: Sun, 28 Jun 2026 09:45:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(policy):=20ship=20ProviderPolicy=20?= =?UTF-8?q?=E2=80=94=20LLM-provider=20egress=20allow-list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 23 ++++++++++++++ src/lib.rs | 2 +- src/policy.rs | 88 +++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 96 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6eec37c..5b6cb04 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,29 @@ each reload); **file/exec** = one path per line. Prefer in-process (Rust) embedd the `Policy` trait (`egress` / `file_write` / `exec` → `Verdict`). Full design + both paths: [`docs/enforcement.md`](docs/enforcement.md). +**Built-in `ProviderPolicy`** — a shipped `Policy` that allow-lists egress **by LLM provider** +(classified from SNI by the default `SniClassifier`, or any `ServiceClassifier` via +`.with_classifier(classifier, allowed)`) and **denies any connection whose provider isn't on the +list** — the `connect4`/cgroup guard enforces the deny in-kernel. observer's side of "keep the +agent on approved models, off the unapproved API relay / supply chain". **Egress-only** — +file/exec stay fail-open. It's the proactive complement to +[a3s-sentry](https://github.com/A3S-Lab/Sentry)'s *reactive* per-destination denies (only approved +providers are ever reachable in the first place), and **host-buildable** — it adds no eBPF, the +core is untouched: + +```rust +use a3s_observer::{Provider, ProviderPolicy}; + +// Default: only a *known, non-approved* provider is denied; unknown destinations +// (package mirrors, telemetry, your own APIs) still pass — deny_unclassified is false. +let policy = ProviderPolicy::new([Provider::Anthropic, Provider::OpenAi]); +// api.anthropic.com → Allow · api.deepseek.com → Deny (known provider, not approved) · github.com → Allow + +// Strict "approved providers only" cage: anything that isn't allow-listed — incl. unknown hosts — is denied. +let cage = ProviderPolicy::new([Provider::Anthropic]).deny_unclassified(true); +// api.anthropic.com → Allow · github.com → Deny · no-SNI → Deny +``` + ## Why eBPF, and the boundary - **Zero-instrumentation, language-agnostic** — observe or guard any agent (Python/Node/Go/Rust) diff --git a/src/lib.rs b/src/lib.rs index 2870b5b..fcd1341 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,7 +18,7 @@ pub mod policy; pub mod traits; pub use model::{AgentEvent, EnrichedEvent}; -pub use policy::{parse_egress_policy, AllowAll, Policy, Verdict}; +pub use policy::{parse_egress_policy, AllowAll, Policy, ProviderPolicy, Verdict}; pub use traits::{ read_ppid, Exporter, Identity, IdentityResolver, JsonExporter, KubeResolver, LogExporter, ProcResolver, Provider, ServiceClassifier, SniClassifier, diff --git a/src/policy.rs b/src/policy.rs index 4f779ab..4c02424 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -11,7 +11,7 @@ //! enforcement eBPF (LSM / TC) reads inline — eBPF can't do a userspace round-trip per //! syscall. Default is fail-open ([`AllowAll`]): never block unless a policy opts in. -use crate::traits::Identity; +use crate::traits::{Identity, Provider, ServiceClassifier, SniClassifier}; use std::net::{IpAddr, Ipv4Addr}; /// Parse an egress-policy file body — the external interface's input contract. One entry per @@ -62,20 +62,61 @@ pub trait Policy: Send + Sync { pub struct AllowAll; impl Policy for AllowAll {} -#[cfg(test)] -mod tests { - use super::*; +/// An egress allow-list **by LLM provider** — observer's side of agentfw's "keep the agent on +/// approved models, off the unapproved API relay / supply chain". Each outbound connection is +/// classified by a [`ServiceClassifier`] (SNI → [`Provider`]) and **denied unless its provider is on +/// the allow-list**; observer's `connect4`/cgroup guard enforces the deny in-kernel. Egress-only — +/// file/exec stay fail-open. This is the in-process counterpart to driving the egress deny-file from +/// an external controller (e.g. a3s-sentry), and the proactive complement to sentry's *reactive* +/// per-destination denies: only approved providers are ever reachable in the first place. +pub struct ProviderPolicy { + classifier: C, + allowed: Vec, + deny_unclassified: bool, +} + +impl ProviderPolicy { + /// Allow-list these providers, classifying egress with the default [`SniClassifier`]. + pub fn new(allowed: impl IntoIterator) -> Self { + Self::with_classifier(SniClassifier, allowed) + } +} + +impl ProviderPolicy { + /// Allow-list these providers, classifying with a custom [`ServiceClassifier`]. + pub fn with_classifier(classifier: C, allowed: impl IntoIterator) -> Self { + Self { + classifier, + allowed: allowed.into_iter().collect(), + deny_unclassified: false, + } + } + + /// Also deny egress that matches **no** known provider — a strict "approved LLM providers only" + /// cage. Default off, so unknown destinations (package mirrors, telemetry, your own APIs) still + /// pass; turn on when the agent should reach nothing but its allow-listed models. + pub fn deny_unclassified(mut self, yes: bool) -> Self { + self.deny_unclassified = yes; + self + } +} - // A sample external policy: an egress allowlist (fail-closed for egress only). - struct ProviderAllowlist(&'static [&'static str]); - impl Policy for ProviderAllowlist { - fn egress(&self, _id: &Identity, sni: Option<&str>, _peer: IpAddr) -> Verdict { - match sni { - Some(h) if self.0.iter().any(|a| h.ends_with(a)) => Verdict::Allow, - _ => Verdict::Deny, - } +impl Policy for ProviderPolicy { + fn egress(&self, _id: &Identity, sni: Option<&str>, peer: IpAddr) -> Verdict { + match self.classifier.classify(sni, peer) { + // A known provider — allow iff it's on the list. + Some(p) if self.allowed.contains(&p) => Verdict::Allow, + Some(_) => Verdict::Deny, + // Unclassified destination — gate per the strict-cage knob. + None if self.deny_unclassified => Verdict::Deny, + None => Verdict::Allow, } } +} + +#[cfg(test)] +mod tests { + use super::*; #[test] fn allow_all_never_blocks() { @@ -86,16 +127,31 @@ mod tests { } #[test] - fn external_allowlist_gates_egress_only() { - let p = ProviderAllowlist(&["anthropic.com", "openai.com"]); + fn provider_policy_allows_listed_denies_other_providers() { + let p = ProviderPolicy::new([Provider::Anthropic, Provider::OpenAi]); let id = Identity::default(); let ip = IpAddr::from([0, 0, 0, 0]); + // allow-listed provider → allow assert_eq!(p.egress(&id, Some("api.anthropic.com"), ip), Verdict::Allow); - assert_eq!(p.egress(&id, Some("evil.example.com"), ip), Verdict::Deny); - // non-egress actions still default to allow + // a *known* provider not on the list → deny (an unapproved model/relay) + assert_eq!(p.egress(&id, Some("api.deepseek.com"), ip), Verdict::Deny); + // unclassified destination → allow by default (don't cut non-LLM traffic) + assert_eq!(p.egress(&id, Some("github.com"), ip), Verdict::Allow); + // egress-only: file/exec still fail-open assert_eq!(p.file_write(&id, "/tmp/x"), Verdict::Allow); } + #[test] + fn provider_policy_strict_cage_denies_unclassified() { + let p = ProviderPolicy::new([Provider::Anthropic]).deny_unclassified(true); + let id = Identity::default(); + let ip = IpAddr::from([0, 0, 0, 0]); + assert_eq!(p.egress(&id, Some("api.anthropic.com"), ip), Verdict::Allow); + // strict cage: anything that isn't an approved provider — incl. unknown hosts — is denied + assert_eq!(p.egress(&id, Some("github.com"), ip), Verdict::Deny); + assert_eq!(p.egress(&id, None, ip), Verdict::Deny); + } + #[test] fn parses_egress_policy_file() { let (ips, hosts) =