| name | plugin-dev |
| description | Guide for writing a new Pedro BPF plugin — structured output via .pedro_meta, build setup, and testing. |
BPF Plugin Development
Pedro plugins are standalone .bpf.o ELF files loaded at runtime via --plugins. They hook kernel
events, write to pedro's shared ring buffer, and may set flags on task_context to influence
policy. Structured output is declared statically in a .pedro_meta ELF section so pedro can write
typed Parquet columns without any userland code from the plugin.
Use this skill when writing a new plugin or modifying an existing one.
Reference
The canonical example is e2e/test_plugin.bpf.c. Read it first. It covers every concept below in
under 100 lines.
Plugin anatomy
A plugin .bpf.c file always has four parts:
-
Includes — vmlinux.h first, then bpf/bpf_*.h, then:
#include "pedro-lsm/lsm/kernel/maps.h"
#include "pedro/messages/plugin_meta.h"
Including maps.h declares pedro's kernel maps. The plugin loader matches maps by name+type and
reuses pedro's fds — the plugin's declarations don't create duplicates.
-
.pedro_meta section — required. One pedro_plugin_meta_t in SEC(".pedro_meta"). Plugins
without it are rejected at load time.
-
BPF programs — SEC("lsm/...") or other attach points. All programs in the object are
attached automatically.
-
License — char LICENSE[] SEC("license") = "GPL";
Structured output: .pedro_meta
Declare a pedro_plugin_meta_t to describe every event type the plugin emits. This drives Parquet
schema generation — column names and types come straight from here.
#define PLUGIN_ID 42
#define MY_EVENT 1
pedro_plugin_meta_t my_plugin_meta SEC(".pedro_meta") = {
.magic = PEDRO_PLUGIN_META_MAGIC,
.version = PEDRO_PLUGIN_META_VERSION,
.plugin_id = PLUGIN_ID,
.name = "my_plugin",
.event_type_count = 1,
.event_types = {{
.event_type = MY_EVENT,
.msg_kind = kMsgKindEventGenericSingle,
.name = "my_event",
.column_count = 3,
.columns = {
{.name = "pid", .type = kColumnU32, .slot = 0, .offset = 0},
{.name = "uid", .type = kColumnU32, .slot = 0, .offset = 4},
{.name = "comm", .type = kColumnString, .slot = 1},
},
}},
};
Picking a size (msg_kind):
| msg_kind | slots | struct | wire size |
|---|
kMsgKindEventGenericHalf | 1 | EventGenericHalf | 32 B |
kMsgKindEventGenericSingle | 5 | EventGenericSingle | 64 B |
kMsgKindEventGenericDouble | 13 | EventGenericDouble | 128 B |
Pick the smallest that fits — ring-buffer bandwidth matters.
Column types (see pedro/messages/plugin_meta.h):
kColumnU64, kColumnI64 — whole slot, offset must be 0
kColumnU32, kColumnI32 — 4 bytes; offset ∈ {0, 4}
kColumnU16, kColumnI16 — 2 bytes; offset ∈ {0, 2, 4, 6}
kColumnString — whole slot. Strings ≤7 bytes are inlined via field.str.intern; longer strings
need Chunk delivery (see pedro-lsm/lsm/kernel/maps.h for the helpers).
kColumnBytes8 — whole slot, raw 8-byte binary
kColumnCookie — whole slot, write a u64 process cookie. Userland prepends the boot UUID and
stores it as a nullable string column. A cookie of 0 is written as null. If the column name ends
in _cookie, the parquet column is renamed to end in _uuid instead.
Implicit columns: every plugin table starts with a common struct column (boot_uuid,
machine_id, hostname, event_time, processed_time, event_id, sensor) matching the built-in exec and
heartbeat tables. Plugin columns follow at index 1.
Sub-word packing: multiple columns can share one slot at different offsets. The pid + uid
example above packs two u32 into one 8-byte slot. Userland extracts them via the offsets — BPF just
writes the union.
Limits: 8 event types per plugin, 31 columns per event type.
Shared tables: set .flags = PEDRO_ET_SHARED and a .name on an event type to let multiple
plugins write to one table. Every plugin declaring the same shared event_type must use an
identical schema (msg_kind, name, columns) or pedro refuses to load. In BPF, emit with
ev->key.plugin_id = PEDRO_SHARED_PLUGIN_ID instead of your own id. See
e2e/test_plugin_shared.bpf.c.
Writer names (parquet output in the spool dir):
- shared event type →
{name}
- private event type with a name →
{plugin.name}_{name}
- private event type without a name →
plugin_{id}_{event_type}
Names must match [a-z][a-z0-9_-]* and be unique across all loaded plugins plus the built-in tables
(exec, heartbeat, human_readable).
Emitting events
Reserve from the shared rb ring buffer, fill, submit:
static inline void emit_my_event(u32 pid, u32 uid, const char *comm) {
EventGenericSingle *ev =
bpf_ringbuf_reserve(&rb, sizeof(EventGenericSingle), 0);
if (!ev) return;
__builtin_memset(ev, 0, sizeof(*ev));
ev->hdr.kind = kMsgKindEventGenericSingle;
ev->hdr.nsec_since_boot = bpf_ktime_get_boot_ns();
ev->key.plugin_id = PLUGIN_ID;
ev->key.event_type = MY_EVENT;
ev->field1.u32[0] = pid;
ev->field1.u32[1] = uid;
bpf_probe_read_kernel_str(ev->field2.str.intern,
sizeof(ev->field2.str.intern), comm);
bpf_ringbuf_submit(ev, 0);
}
GenericWord is a union: .u64, .i64, .u32[2], .i32[2], .u16[4], .i16[4], .bytes[8],
.str. Match your writes to what the metadata declares — pedro trusts the metadata, not runtime
introspection.
Build
Plugins use the bpf_obj rule from //:bpf.bzl:
load("//:bpf.bzl", "bpf_obj")
bpf_obj(
name = "my_plugin",
src = "my_plugin.bpf.c",
hdrs = ["//pedro-lsm/lsm:kernel-headers"],
)
This produces my_plugin-bpf-obj (the .bpf.o file). For an in-tree plugin put it under e2e/
alongside test_plugin.
bazel build //e2e:my_plugin-bpf-obj
Running
./scripts/pedro.sh -- --plugins=bazel-bin/e2e/my_plugin.bpf.o --allow-unsigned-plugins
--allow-unsigned-plugins is required unless the plugin is signed (see below). Parquet output lands
in the spool dir under the event type's writer name (see above).
Signing
Production builds embed a public key; plugins must be signed with the matching private key. Use
plugin-tool:
bazel run //bin:plugin-tool -- sign --key testdata/plugin.key my_plugin.bpf.o signed.bpf.o
bazel run //bin:plugin-tool -- verify --pubkey testdata/plugin.pub signed.bpf.o
See e2e/tests/e2e/plugin_signing.rs for the end-to-end flow.
Testing
Add a Rust e2e test under e2e/tests/e2e/. Pattern follows plugin_generic.rs:
PedroProcess::try_new with PedroArgsBuilder.plugins(vec![path])
- Trigger the hook (spawn a helper binary, touch a file, etc.)
pedro.stop()
- Read Parquet via
pedro.parquet_reader_with_schema(WRITER_NAME, schema) (see Writer names above;
"my_plugin_my_event" for this example)
- Assert on column values
Add the .bpf.o to the data list of :e2e_test in e2e/BUILD.
./scripts/quick_test.sh -a my_plugin_test_name
Pitfalls
- plugin_id collision — pedro refuses to start if two plugins share an id. Pick a unique
non-zero value.
- Slot/type mismatch — Rust validation rejects metadata where
slot >= max_slots for the
msg_kind, or where offset + type_size > 8. These fail at load time with a clear message.
- Missing
.pedro_meta — the plugin is rejected. The section is mandatory.
- BPF verifier — same rules as any BPF program. Keep loops bounded, check
bpf_ringbuf_reserve
return. sudo cat /sys/kernel/debug/tracing/trace shows bpf_printk output for debugging.