CONTENTS
0 what plugins exist for
1 the named pipe and its acl
2 framing — u32 length, then json
3 the handshake
4 steady state — push only
5 reject reasons, goodbye, ctrl+c
──[ 0. What Plugins Exist For ]──
The kernel callbacks of Part 2 give us five sources of telemetry. That is the
full menu of "what a driver can observe through registered callbacks". An EDR
cares about more: Windows Event Log entries, Defender detections, what Sysmon
would catch if it were installed, custom-application audit trails. None of this
is reachable from a kernel callback.
Two designs solve this. Either every source is baked into the agent, growing it
indefinitely, or a stable interface is defined and the sources become
independent programs that connect to it. We chose the second: a **plugin** is a
separate process that connects to the agent over a named pipe and pushes
JSON-encoded events. The agent ingests them through the same spool/shipper
pipeline as kernel events. Plugins are independently versioned, independently
signed, and independently startable.
This post defines the protocol. Part 8 covers the SDK that abstracts it. Part 9
covers the manifest mechanism that authorises specific plugins. Part 10 walks
through a real one.
──[ 1. The Named Pipe and Its ACL ]──
\\.\pipe\WazabiEDR_plugin
Named pipes (*named pipe*: a Windows IPC mechanism that exposes a duplex byte st
ream identified by a name in the `DeviceNamedPipe` kernel namespace, accessed fr
om user mode through Win32 file APIs like `CreateFileW`) are the chosen transpor
t. One pipe, served by the agent. Plugins connect via `CreateFileW(L"\\.\pipe\Wa
zabiEDR_plugin", …)`. Multiple plugins can be connected simultaneously; the agen
t spawns a worker per connection, each holding its own session state.
The choice of named pipe over a TCP loopback socket comes down to two
properties:
The pipe lives entirely in kernel memory. There is no network stack involved, no
port allocation, no firewall configuration. A program that can reach the pipe is
by definition local — we do not need to invent a "is this localhost?" check.
The pipe carries an ACL (the access control descriptor we covered in Part 0).
The pipe is created with a discretionary ACL that restricts connect access to
processes running as either the same user as the agent or as `SYSTEM`. A regular
interactive user cannot connect; the security boundary is enforced at the
pipe-creation site, not in the protocol.
──[ 2. Framing — u32 Length, then JSON ]──
Every frame in either direction has the same shape:
┌──────────────────┬───────────────────────────────────┐
│ u32 LE length │ length bytes of utf-8 JSON │
└──────────────────┴───────────────────────────────────┘
Length-prefixed framing is mandatory because a named pipe in byte-stream mode
(`PIPE_TYPE_BYTE`) delivers a stream rather than discrete messages — the
consumer has no implicit boundary between writes from the producer. The 4-byte
little-endian length header tells the consumer exactly how many bytes to read
for the next frame.
A size cap is enforced on both sides:
pub const MAX_FRAME_BYTES: usize = 1 * 1024 * 1024;
1 MiB. The SDK enforces it on outbound to give the plugin author a typed error
rather than a silent disconnect when a frame would exceed the cap. The agent
enforces it on inbound for the same reason on its side, and additionally as a
defence against a corrupted or hostile producer asking for a multi-gigabyte
allocation.
JSON over a perfectly serviceable binary pipe deserves a sentence of
justification. The driver's wire format (Part 3) is binary because it sits on a
kernel hot path and the kernel cannot afford a serializer. The plugin protocol
does not have those constraints — plugins are written by third parties in
arbitrary languages, the volumes are smaller than the driver firehose (plugins
typically emit "interesting" events, not raw activity), and "the API takes JSON"
is the lowest-friction contract for the largest set of plugin authors.
──[ 3. The Handshake ]──
A new connection is not yet a session. Both sides must speak once first:
plugin agent
────── ─────
│── hello ───────────────────►│
│ │ manifest lookup,
│ │ expected_path verification,
│ │ expected_sha256 verification
│ │
│◄────────────── hello_ack ───│
│ │
│── event ───────────────────►│
│── event ───────────────────►│
│── heartbeat ───────────────►│
│── event ───────────────────►│
│── goodbye ─────────────────►│
│ (pipe closed) │
The plugin's `Hello` frame identifies itself:
#[derive(Debug, Serialize)]
pub struct Hello<'a> {
pub plugin_id: &'a str, // UUID from the manifest
pub plugin_version: &'a str, // semver, informative
pub schema_version: u32, // 1 in this revision
}
The agent looks up the manifest by `plugin_id`. If none exists, the plugin
receives a `Reject`. If one exists, the agent verifies the connecting process:
its image path must match `expected_path` and its SHA-256 must match
`expected_sha256`. The verification target is the *process* (identified via
`GetNamedPipeClientProcessId`, which returns the PID of the process at the other
end of the pipe), not the executable file on disk — the SHA-256 check is
performed against the running image to detect tampering between disk and load.
On success the agent replies:
#[derive(Debug, Deserialize)]
pub struct HelloAck {
pub session_id: String,
pub server_version: String,
pub max_payload_bytes: u32,
pub heartbeat_sec: u32,
}
`session_id` correlates entries across the agent's stderr log.
`max_payload_bytes` is the per-event payload cap, smaller than `MAX_FRAME_BYTES`
because the JSON envelope plus headers consume some of the frame.
`heartbeat_sec` is the cadence the agent expects a heartbeat at when the plugin
is otherwise idle (default 30 s; configurable via the `Runner` builder in Part
8).
──[ 4. Steady State — Push Only ]──
After `HelloAck`, the agent never writes another frame. The pipe goes one-way:
plugin → agent. The SDK does not even spawn a reader thread.
The plugin emits three frame kinds:
#[derive(Debug, Serialize)]
pub struct Event<'a> {
pub seq: u64,
pub ts_unix_ns: u64,
pub kind: &'a str,
pub payload: &'a serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct Heartbeat { pub seq: u64 }
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientFrame<'a> {
Hello(Hello<'a>),
Event(Event<'a>),
Heartbeat(Heartbeat),
Goodbye {},
}
`Event` is the working frame. `kind` is a routing key
(`"defender.threat_detected"`, `"app.user_login"`, …) used by the agent to tag
the event in the spool and by downstream rule engines for pattern matching.
`payload` is opaque JSON — the plugin chooses what to put there. `seq` is
monotonic per session and lets the agent detect frame loss within a session
(rare in practice — the pipe is reliable byte-stream — but cheap to surface).
`Heartbeat` is sent by the SDK on a background thread when the plugin has not
emitted an `Event` within `heartbeat_sec`. It distinguishes "plugin is alive but
idle" from "plugin crashed and the OS has not noticed yet". Plugin authors do
not call it directly.
`Goodbye` is emitted by the SDK immediately before closing the pipe on a clean
shutdown (Ctrl+C, parent service stop). Functionally it is cosmetic — the agent
observes the same broken pipe whether or not Goodbye preceded it — but it lets
the agent log `goodbye received` rather than `EOF on pipe`, which improves the
readability of stderr during debugging.
──[ 5. Reject Reasons, Goodbye, Ctrl+C ]──
The agent rejects a `Hello` for a small set of well-defined reasons:
#[derive(Debug, Deserialize)]
pub struct RejectFrame {
pub reason: String,
}
Documented `reason` strings:
unknown_plugin — plugin_id has no manifest in the directory
path_mismatch — connecting process's image path does not
equal manifest.expected_path
sha256_mismatch — running image SHA-256 does not equal
manifest.expected_sha256
revoked — manifest exists but its revoked field is
true (see Part 9)
schema_mismatch — plugin's schema_version != server's
The reasons are plain strings rather than coded integers because their consumers
are humans — the operator running `wedr-plugin doctor` (Part 9), the developer
reading agent stderr — and the protocol is narrow enough that a flat enumeration
of clear strings is more discoverable than a numeric code dictionary.
After `HelloAck`, the connection is single-shot. If anything interrupts it — the
agent restarts, the pipe write fails with `ERROR_BROKEN_PIPE`, the OS evicts the
pipe under extreme memory pressure — the SDK does not attempt reconnection. The
plugin's `main` receives the error and exits. Restart of the plugin process is
the responsibility of a service supervisor: either the OS service controller for
plugins installed as services, or the agent itself for plugins flagged
`auto_launch = true` in their manifest (Part 9).
Next post: the SDK that hides the protocol behind a `trait Plugin` / closure
abstraction, leaving the plugin author with only the producer logic to write.