Skip to content

feat(agent): eBPF load-time BTF preflight, retire manual offset re-verification (ADR-0014 amendment) - #335

Merged
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-328-ebpf-bindings-eliminate-manual-offset-maintenance-real-co-re
Aug 8, 2026
Merged

feat(agent): eBPF load-time BTF preflight, retire manual offset re-verification (ADR-0014 amendment)#335
thejefflarson merged 2 commits into
mainfrom
thejefflarson/jef-328-ebpf-bindings-eliminate-manual-offset-maintenance-real-co-re

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

Implements JEF-328: a load-time BTF preflight for the eBPF agent's userspace loader, per the settled design in docs/ideas/ebpf-offset-self-verification.md and the ADR-0014 amendment (both landed in #331).

The agent bakes hand-verified kernel struct offsets into its eBPF probes (no CO-RE — rustc emits no BTF field relocations). Before this change, the compile-time offset_of! guard only proved the bindings were internally consistent — it couldn't catch a new kernel silently moving a field, which for bpf_probe_read_kernel chases (unlike bpf_d_path) means a stale offset reads garbage silently rather than failing the verifier.

What's new:

  1. Single source of truth — a const (struct, field, expected-offset) table + the LOADING_MODULE enum value in agent/common/src/offsets.rs (shared no_std crate). The eBPF crate's offset_of! guard (vmlinux.rs) now asserts bindings == table at compile time (no number lives in two places).
  2. Load-time preflight in the userspace loader (agent/protector-agent/src/preflight): before attach, walks the node's live BTF and verifies every table entry's offset + the enum value, recursing into anonymous unions/structs (inode.i_nlink lives in one). Neither aya::Btf nor aya-obj::Btf's public API exposes struct-member offsets or enum values (both are pub(crate) upstream in aya-obj 0.2.1 — confirmed by reading its source), so this is a small, self-contained direct parse of the raw BTF binary format, as the brief's risk section anticipated as the likely outcome.
  3. Fail-closed on struct-reading probes (file_open, file_write, mmap_file, fix_setuid, bprm_check), fail-open on struct-free probes (connect, ptrace_access_check, kernel_load_data) — see the STRUCT_DEPS table in agent/protector-agent/src/observer/ebpf/preflight_gate.rs. Every divergent field is logged expected-vs-actual (the regeneration data an operator needs); a LOADING_MODULE enum mismatch is logged but never gates a probe (not verifier-checked either way, per the design). Degrades gracefully — a missing/corrupt BTF blob fails closed on every struct-reading probe rather than crashing (ADR-0014).
  4. Resolves the ON-NODE-PENDING markers in vmlinux.rs — the preflight is now their continuous, per-node verification, not a one-time manual bpftool btf dump task.
  5. Corrects the stale "CO-RE-relocated against node BTF at load" claims in agent/Dockerfile, .github/workflows/agent.yml, and docs/ebpf-testing-on-nodes.md — the object bakes offsets and the loader checks them at load; it does not relocate.

No PROTECTOR_*_ENABLE toggle — this is a correctness guard, not a feature (repo convention).

Testing

  • Unit tests (off-fleet, plain userspace Rust, no bpf toolchain or live kernel needed):
    • preflight/btf_tests.rs — the raw BTF parser against hand-built fixture blobs: plain struct field lookup, unknown struct/field, anonymous-union recursion (mirrors inode.i_nlink), a typedef wrapping an anonymous member's type, enum variant lookup, big-endian header detection, truncated/bad-magic rejection, and a bounded-recursion regression test against a self-referential type (a defensive fix found during my own security self-review — an unbounded anonymous-member recursion could otherwise stack-overflow on a malformed blob, which would violate ADR-0014's "never crash-loop" invariant even though /sys/kernel/btf/vmlinux isn't normally attacker-controlled).
    • preflight/tests.rs — end-to-end check()/check_bytes() against a fixture that mirrors the full FIELD_OFFSETS table: correct offsets pass clean, a moved offset (simulating the 6.8→6.11 struct file reorg) is flagged with expected-vs-actual, the anon-union recursion is independently verified, an enum-value mismatch is flagged independently of field offsets, a struct missing entirely from BTF flags every field with actual: None, and unparseable BTF fails closed on every table entry.
    • common/src/offsets.rs — the compile-time offset_of_table lookup matches every declared entry.
  • Compile-time: agent/protector-agent-ebpf's offset_of! guard now consumes the shared table (cargo check --release / cargo clippy --release -- -D warnings pass in that crate).

Commands run (agent workspace, per its CI job being separate from the engine's):

cargo build / cargo test / cargo fmt --check / cargo clippy --all-targets -- -D warnings   (agent/ — default, no-toolchain build)
cargo check --release / cargo clippy --release -- -D warnings                              (agent/protector-agent-ebpf/)

All green — 72 unit tests pass across protector-agent + protector-agent-common.

Known local-environment limitation (not a regression): cargo build --features ebpf cannot link on this macOS dev box (bpf-linker fails to load its LLVM shared lib — reproduces identically on unmodified main via git stash) or even cargo check (aya itself uses Linux-only libc netlink APIs — this is the exact constraint docs/ebpf-testing-on-nodes.md already documents: "eBPF can't be compiled or load-tested locally (macOS)"). I confirmed the ebpf-feature code path (the new observer/ebpf/preflight_gate.rs submodule wiring, module-path resolution, pub(super) visibility) against a minimal isolated reproduction of the exact module-nesting pattern, which compiled and ran correctly. .github/workflows/agent.yml's ebpf job (Linux self-hosted runners with the bpf toolchain) is the real gate for that path.

Scope notes

  • The STRUCT_DEPS probe→struct dependency table (observer/ebpf/preflight_gate.rs) is hand-maintained, cross-referenced against each probe's kernel-side function in protector-agent-ebpf/src/main.rs — the same acknowledged tradeoff the design brief accepted (no automated way to derive it without parsing the eBPF bytecode itself, well out of scope).
  • observer.rs was at 991 lines after the initial wiring (approaching the repo's 1,000-line file cap); split the preflight-gating table/logic into its own submodule (observer/ebpf/preflight_gate.rs) to bring it back to 904 lines, following the file's existing pattern of nested-module test files.
  • Ran a self-review pass against the repo's security checklist (soundcheck's pr-review skill wasn't invocable as a slash command in this subagent context, so I did a careful manual pass instead): the only actionable finding was the unbounded anonymous-member recursion noted above, fixed with a depth cap + regression test before opening this PR.

Closes JEF-328

🤖 Generated with Claude Code

thejefflarson and others added 2 commits August 8, 2026 12:48
…rification (ADR-0014 amendment)

Adds a load-time BTF preflight to the userspace loader: before attaching any
probe, it re-verifies every kernel struct field offset the eBPF crate bakes
in (plus the LOADING_MODULE enum value) against the node's live BTF.

- Single source of truth: a const (struct, field, expected-offset) table plus
  the LOADING_MODULE value in agent/common/src/offsets.rs. The eBPF crate's
  offset_of! guard (vmlinux.rs) now asserts bindings == table at compile
  time; the loader's preflight asserts table == node-BTF at load time.
- A small self-contained BTF binary parser (agent/protector-agent/src/
  preflight/btf.rs) — neither aya nor aya-obj's public API exposes struct-
  member offsets or enum values (both are pub(crate) upstream), so this
  parses the raw type section directly, recursing into anonymous unions/
  structs (inode.i_nlink) with a bounded recursion depth against a malformed
  blob. Off-fleet testable against hand-built fixture BTF blobs (no live
  kernel required).
- Fail-closed on struct-reading probes (file_open, file_write, mmap_file,
  fix_setuid, bprm_check), fail-open on struct-free probes (connect,
  ptrace_access_check, kernel_load_data). Every divergent field is logged
  expected-vs-actual; the LOADING_MODULE enum mismatch is logged but never
  gates a probe (not verifier-checked either way). Degrades gracefully,
  never crash-loops.
- Resolves the ON-NODE-PENDING markers in vmlinux.rs (the preflight is now
  their continuous verification, not a one-time manual task).
- Corrects the false "CO-RE-relocated against node BTF at load" claims in
  agent/Dockerfile, .github/workflows/agent.yml, and
  docs/ebpf-testing-on-nodes.md — the object bakes offsets and the loader
  checks them at load; it does not relocate.

No PROTECTOR_*_ENABLE toggle: this is a correctness guard, not a feature.

Closes JEF-328

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson

Copy link
Copy Markdown
Owner Author

Merged. Fast-follow (LOW, not a merge blocker) recorded from the integration security pass:

agent/protector-agent/src/preflight/btf.rsEndian::u32 (~line 76) uses .expect("4-byte slice") on a blob-derived slice. Unreachable today (all ~12 callers pass length-guarded 4-byte subslices), but it is the one expect on the untrusted-blob path in a parser whose ADR-0014 contract is never panic on the blob — a future wrong-sized caller would crash-loop the agent on malformed BTF, defeating the degrade-gracefully guarantee this very PR establishes. Fix: make Endian::u32 total (return Result<u32, BtfParseError::Truncated>) and propagate through its callers.

Deferred to a fast-follow rather than applied at merge time because: (1) it is unreachable today, (2) threading Result through ~12 call sites in a hot parser is a real change deserving its own tested PR, and (3) editing this branch re-triggers the slow/flaky self-hosted ebpf CI on an otherwise-clean PR. Needs a tracking ticket (orchestrator has Linear write access).

@thejefflarson
thejefflarson merged commit 87460d7 into main Aug 8, 2026
7 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-328-ebpf-bindings-eliminate-manual-offset-maintenance-real-co-re branch August 8, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant