Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 72 additions & 16 deletions src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<C: ServiceClassifier = SniClassifier> {
classifier: C,
allowed: Vec<Provider>,
deny_unclassified: bool,
}

impl ProviderPolicy<SniClassifier> {
/// Allow-list these providers, classifying egress with the default [`SniClassifier`].
pub fn new(allowed: impl IntoIterator<Item = Provider>) -> Self {
Self::with_classifier(SniClassifier, allowed)
}
}

impl<C: ServiceClassifier> ProviderPolicy<C> {
/// Allow-list these providers, classifying with a custom [`ServiceClassifier`].
pub fn with_classifier(classifier: C, allowed: impl IntoIterator<Item = Provider>) -> 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<C: ServiceClassifier> Policy for ProviderPolicy<C> {
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() {
Expand All @@ -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) =
Expand Down
Loading