Skip to content

Commit ad9e0e0

Browse files
RoyLinRoyLin
authored andcommitted
release: v0.2.5 — --version/--help, committed smoke test, enforcement design + Policy contract
- collector --version / --help - scripts/smoke.sh: committed e2e smoke (builds, unit-tests, loads probes, drives an LLM call, asserts an event) — validated on KVM (5 tests, --version, probes+events). - enforcement extension design (docs/enforcement.md) + Policy/Verdict contract (src/policy.rs, the external-implementable seam; AllowAll fail-open default + sample egress-allowlist; tested). eBPF mechanism is phase 2.
1 parent f35a0ec commit ad9e0e0

11 files changed

Lines changed: 231 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

33
All notable changes to a3s-observer will be documented in this file.
44

5+
## [0.2.5] — operator polish + enforcement design
6+
7+
### Added
8+
9+
- `--version` / `--help` flags on the collector.
10+
- `scripts/smoke.sh` — committed end-to-end smoke test (builds, unit-tests, loads the probes,
11+
drives an LLM call, asserts an event flowed) so the manual validation is reproducible.
12+
- **Enforcement extension — design + contract** (opt-in *intervention*, kept separate from
13+
the observe-only core): `docs/enforcement.md` (architecture) + a `Policy`/`Verdict` seam
14+
(`src/policy.rs`) that an **external** policy implements — in-process (`impl Policy`) or
15+
out-of-process (a controller driving in-kernel policy maps via a control API). Default
16+
`AllowAll` (fail-open). The eBPF mechanism (LSM `file_open`/`bprm` deny, TC egress drop) is
17+
phase 2 and must be validated on a non-prod box. Note: encrypted TLS payload can be
18+
dropped/RST/redirected but not modified.
19+
520
## [0.2.4] — clean shutdown (k8s lifecycle)
621

722
### Changed

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
edition = "2021"
55
license = "MIT"
66
description = "General-purpose, language-agnostic eBPF observability for AI agents (LLM calls, tools, files, network egress)."

a3s-observer-collector/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-collector"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
edition = "2021"
55
license = "MIT"
66
description = "a3s-observer collector: loads the eBPF probes and exports enriched events."

a3s-observer-collector/src/main.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ use std::time::Duration;
2323

2424
#[tokio::main]
2525
async fn main() -> anyhow::Result<()> {
26+
match std::env::args().nth(1).as_deref() {
27+
Some("--version" | "-V") => {
28+
println!("a3s-observer-collector {}", env!("CARGO_PKG_VERSION"));
29+
return Ok(());
30+
}
31+
Some("--help" | "-h") => {
32+
println!(
33+
"a3s-observer-collector {} — language-agnostic eBPF observability for AI agents\n\n\
34+
Run as root / CAP_BPF+CAP_PERFMON (Linux). Configure via env:\n \
35+
A3S_OBSERVER_JSON=1 emit NDJSON (default: human-readable log)\n \
36+
A3S_OBSERVER_FILES=1 also capture file writes (high-volume; off by default)",
37+
env!("CARGO_PKG_VERSION")
38+
);
39+
return Ok(());
40+
}
41+
_ => {}
42+
}
2643
tracing_subscriber::fmt::init();
2744

2845
let mut ebpf = Ebpf::load(aya::include_bytes_aligned!(concat!(

a3s-observer-common/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-common"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
edition = "2021"
55
license = "MIT"
66
description = "Shared no_std types crossing the eBPF <-> userspace boundary for a3s-observer."

a3s-observer-ebpf/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-observer-ebpf"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
edition = "2021"
55
license = "MIT"
66
publish = false

docs/enforcement.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Enforcement (design) — opt-in, externally-implemented intervention
2+
3+
a3s-observer **observes**. This is the design for *optional* intervention (block / redirect),
4+
kept strictly separate so the observer core stays passive and safe.
5+
6+
## Why separate (first principles)
7+
8+
Observation is passive and read-only — tracepoints → ring buffers **cannot block**.
9+
Enforcement is security-critical: a bug blocks legitimate traffic or breaks the agent, and it
10+
needs fail-open/closed semantics + a policy engine. Per "minimal core + external extensions",
11+
enforcement is an **opt-in extension**, never baked into the observer's blast radius.
12+
13+
## Mechanism (what the core extension would provide)
14+
15+
eBPF *can* enforce — but via different hooks than the observer's tracepoints:
16+
17+
| Intervention | eBPF hook | Effect |
18+
|---|---|---|
19+
| Block file open/write | **LSM-BPF** `lsm/file_open`, `lsm/path_*` | return `-EPERM` |
20+
| Block exec | **LSM-BPF** `lsm/bprm_check_security` | return `-EPERM` |
21+
| Block / redirect egress | **TC** egress or **cgroup/connect4** | drop / RST / redirect by SNI or peer |
22+
| Drop at the NIC | **XDP** | drop before the stack |
23+
24+
Honest caveat: you can **drop / RST / redirect** a connection, but you **cannot modify
25+
encrypted (TLS) payload** — it's encrypted. Only plaintext (pre-TLS / non-TLS) is rewritable
26+
via TC, which is rarely useful. LSM hooks need `CONFIG_BPF_LSM` (kernel ≥ 5.7).
27+
28+
## External policy (the pluggable part)
29+
30+
eBPF can't do a userspace round-trip per syscall (too slow to block inline). So the split is:
31+
32+
```
33+
external policy ──writes──▶ BPF policy maps ──inline lookup──▶ enforcement eBPF
34+
(allow/deny rules) (keyed by cgroup / SNI / path) (LSM / TC) → allow | deny
35+
```
36+
37+
The **policy lives outside the core** — two ways to implement it, both first-class:
38+
39+
1. **In-process** — a Rust `Policy` trait impl (mirrors `IdentityResolver` / `Exporter`):
40+
```rust
41+
pub enum Verdict { Allow, Deny }
42+
pub trait Policy: Send + Sync {
43+
fn egress(&self, id: &Identity, sni: Option<&str>, peer: IpAddr) -> Verdict;
44+
fn file_write(&self, id: &Identity, path: &str) -> Verdict;
45+
fn exec(&self, id: &Identity, argv: &[String]) -> Verdict;
46+
}
47+
```
48+
The enforcer compiles verdicts into policy-map entries the eBPF reads inline.
49+
50+
2. **Out-of-process (fully external / language-agnostic)** — a separate controller consumes
51+
the observer's existing event stream (NDJSON / OTel) and pushes verdicts through a
52+
**control API** (CLI / unix-socket / gRPC that updates the policy maps). The policy engine
53+
(OPA/Rego, a service, your own code in any language) lives entirely outside the binary; the
54+
core only enforces what the maps say. **This is the "外部实现" path.**
55+
56+
Default policy = `AllowAll` (fail-open — never break an agent unless a rule opts in).
57+
58+
## Fail-safe
59+
60+
- **fail-open** (default): unknown → allow. Observability-first; never break the agent.
61+
- **fail-closed**: unknown → deny (e.g., an egress allowlist). Security-first; opt-in per rule.
62+
63+
Every deny is *also* emitted as an observed event, so enforcement is auditable.
64+
65+
## Staged plan (same shape the observer was built)
66+
67+
1. Contract + this design + `Policy` / `Verdict` (the seam).
68+
2. PoC: TC-egress **SNI allowlist** — drop the ClientHello to non-approved providers, driven
69+
by a policy map. Validate on a **non-prod box**.
70+
3. PoC: LSM `file_open` deny for a path policy.
71+
4. Control API for the out-of-process policy path (the language-agnostic external engine).
72+
5. Harden: fail-safe semantics, per-cgroup scoping, audit-every-deny.
73+
74+
> Enforcement must be validated on a **non-prod box** — blocking real syscalls/egress on a
75+
> shared prod node is unacceptable.

scripts/smoke.sh

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env bash
2+
# End-to-end smoke test for a3s-observer. Run on a Linux box with root (CAP_BPF) + the eBPF
3+
# toolchain (nightly + rust-src + bpf-linker). Builds, loads the probes, drives one LLM call,
4+
# and checks an event flows. Exits non-zero on failure. CI can't run this (needs a real
5+
# kernel + root); it makes the manual validation reproducible.
6+
#
7+
# sudo ./scripts/smoke.sh
8+
set -euo pipefail
9+
cd "$(dirname "$0")/.."
10+
11+
# rustup installs to ~/.cargo/bin; make this work in a non-login shell too.
12+
command -v cargo >/dev/null || { [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"; }
13+
14+
echo "== lib + parser unit tests =="
15+
cargo test -p a3s-observer -p a3s-observer-common
16+
17+
echo "== build collector (compiles the eBPF crate) =="
18+
cargo build --release -p a3s-observer-collector
19+
bin=target/release/a3s-observer-collector
20+
21+
echo "== --version =="
22+
"$bin" --version
23+
24+
echo "== run + drive one LLM call (need root) =="
25+
log=$(mktemp)
26+
RUST_LOG=info A3S_OBSERVER_JSON=1 timeout -s INT 8 "$bin" >"$log" 2>&1 &
27+
pid=$!
28+
sleep 3
29+
curl -s -o /dev/null --max-time 4 https://api.anthropic.com/ 2>/dev/null || true
30+
wait "$pid" 2>/dev/null || true
31+
32+
echo "== checks =="
33+
grep -q "probes attached" "$log" || { echo "FAIL: probes did not attach"; cat "$log"; rm -f "$log"; exit 1; }
34+
grep -aq '"event"' "$log" || { echo "FAIL: no events captured"; rm -f "$log"; exit 1; }
35+
echo "PASS: probes attached and events flowed"
36+
rm -f "$log"

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
//! the collector that loads them in `a3s-observer-collector`.
1515
1616
pub mod model;
17+
pub mod policy;
1718
pub mod traits;
1819

1920
pub use model::{AgentEvent, EnrichedEvent};
21+
pub use policy::{AllowAll, Policy, Verdict};
2022
pub use traits::{
2123
read_ppid, Exporter, Identity, IdentityResolver, JsonExporter, KubeResolver, LogExporter,
2224
ProcResolver, Provider, ServiceClassifier, SniClassifier,

0 commit comments

Comments
 (0)