A kernel-level warden for AI coding agents. Wardyn watches an agent's process
tree with eBPF and enforces — in real time, at the syscall boundary — what it may
read, run, and connect to. It catches the agent reading your .env
or dialing an unknown IP, and can block it before the operation completes.
Recorded on a BPF-LSM kernel, so every ⛔BLOCK row is a real
-EPERM — see docs/RECORDING.md to reproduce it.
$ sudo wardyn --enforce run -- claude "refactor the auth module"
PID COMM EVENT ACT DETAIL
40218 claude exec ok /usr/bin/node
40231 node open ok /home/me/project/src/auth.rs
40231 node open ⛔BLOCK /home/me/.ssh/id_ed25519 [**/.ssh/**]
40244 node exec ⚠ warn /usr/bin/curl
40244 curl connect ⛔BLOCK 185.220.101.7:443 [cidr:0.0.0.0/0]
40250 node open ⛔BLOCK /home/me/project/.env [**/.env]
wardyn: 4 policy violation(s) logged to wardyn-audit.jsonl
wardyn: 3 denial(s) receipted to /tmp/wardyn-denials-40217.jsonl (WARDYN_DENIALS in the agent's env)
⚠️ Status: early development, v0.4.0. Kernel-level enforcement for files, execs and network (TCP + UDP, IPv4 + IPv6); identity (dev, ino) rules; Landlock containment for both the filesystem and TCP ports; stored approvals; a JSON event stream. Not production-ready, and maintained by one person — see Roadmap and GOVERNANCE.md.
You hand an autonomous agent a shell. It should build your project — not exfiltrate
~/.ssh, POST your .env to an unknown host, or spawn a reverse shell. Userspace
guards (seccomp wrappers, LD_PRELOAD, ptrace) are bypassable and race-prone.
Wardyn runs in the kernel: the watched process can't see it, can't unload it, and Wardyn denies the syscall itself — synchronously, before it completes.
For the process subtree you launch (wardyn run -- <cmd>, followed across fork):
| Axis | Observe | Enforce (--enforce) |
eBPF hook |
|---|---|---|---|
| exec — programs run | ✅ path + comm | ⛔ deny blocked binaries, by name or identity | tracepoint/execve·execveat + LSM bprm_check_security |
| file — files opened | ✅ path + access | ⛔ deny secret reads (.env, .ssh/*), by name or identity, and per read/write |
tracepoint/openat·openat2 + LSM file_open |
| filesystem containment | — | ⛔ the agent reaches only allow_paths:, nothing else |
Landlock (no privilege needed) |
| file — names created or removed | ⛔ only when refused (no tracepoint) | ⛔ deny rm, rmdir, mv, ln, ln -s and file creation, by name or identity |
LSM inode_unlink / inode_rmdir / inode_rename / inode_create / inode_mkdir / inode_link / inode_symlink |
| network — egress | ✅ dest ip:port | ⛔ deny blocked CIDRs (TCP + UDP, IPv4/IPv6) | tracepoint/connect·sendto + cgroup/connect4·6 + sendmsg4·6 |
| TCP containment | — | ⛔ the agent reaches only allow_ports:, in or out |
Landlock ABI 4 (no privilege needed) |
Rules match names or identities. A match: rule is a glob over the path —
it covers files that do not exist yet, and it comes off with a single mv. A
path: rule pins the object itself: wardyn resolves it to (dev, ino) when the
policy loads, and the kernel keys on that. Renaming the file does not help, hard
-linking it does not help, and copying it does not help either — a copy has to
read the source, and the read is exactly what is denied.
Every action is checked against a policy.yaml → allow / warn /
block, shown live (coloured) and written to a JSONL audit log. Under
--enforce the agent itself gets a machine-readable denial receipt
(WARDYN_DENIALS) — see Telling the agent.
A denial is reported by the hook that made it. The tracepoints observe a
userspace path string; the LSM and cgroup hooks act on the resolved object and
emit their own event naming the key they matched. So an open through a directory
fd, a symlinked path, or a sendmsg() destination — none of which the observed
string describes correctly — still shows up in the feed, the audit log and the
receipt. What the kernel could not tell you is counted too: ring-buffer drops and
watch-set saturation appear in the header and at exit, because a lost event for a
denied action is a missing audit record, not a cosmetic glitch.
Surgically scoped & safe: enforcement only ever touches the subtree you
launched, and only with --enforce. The rest of the system is never affected —
wardyn --enforce run -- agent can block the agent from 8.8.8.8 while every other
process on the host reaches it fine.
Four scenarios that try to break wardyn rather than demonstrate it. Same
sixteen-line policy for all four, so nothing is stopped by a rule you cannot
see. just stress runs them; the write-up is in
docs/stress/.
| what it tries | result | |
|---|---|---|
| 1 | every known way around a secret-file rule — rename, hard link, move the parent directory, symlink, ../ |
8 attacks, 8 stopped |
| 2 | the agent attacks the warden: BPF maps, /proc/kcore, kill it, setuid, user namespace, rewrite the policy |
15 attempts, 14 refused |
| 3 | 450 processes and 40,000 opens | secret still denied; ~13,000 events dropped, and reported |
| 4 | git, gcc, make, python3, 300 files — does ordinary work still happen? | 15 operations, none broken |
1 · escape. The second half is the control: the same
attacks against name rules instead of (dev, ino), where the hard link goes
straight through. Without it, “8 blocked” says nothing about what did the blocking.
2 · self-defence. The one attempt that works — appending to a policy file — is the weakness wardyn already warns about, and the next line shows the secret still denied: the running policy is in kernel maps, so a file edit reaches the next run.
3 · load. Enforcement is decided inside the syscall; observation goes through a ring buffer. Only one of those can be outrun — and when it is, wardyn says so in numbers. A silent drop would be the real failure.
4 · real work. A sandbox that denies everything passes every attack test and gets switched off on the second day.
Wardyn needs Linux with BTF, cgroup v2, and — for file/exec blocking —
BPF LSM enabled. On macOS, run it in a Linux VM. On Windows, use WSL2 —
its kernel already ships BTF, cgroup v2 and CONFIG_BPF_LSM=y, and one line in
.wslconfig turns the LSM on, so the full tool (file and exec blocking included)
runs and is testable there. See docs/WSL2.md.
Prebuilt binary (x86_64 and arm64, statically linked — no toolchain, no glibc floor; the eBPF object is compiled into it, so this one file is the whole tool):
# from https://github.com/kadircanyildirm-crypto/wardyn/releases
tar xzf wardyn-*-$(uname -m)-unknown-linux-musl.tar.gz && cd wardyn-*/
sha256sum -c ../wardyn-*.tar.gz.sha256 # verify what you downloaded
./wardyn --dry-run --policy policy.yaml # what will this policy do? (no root)Or build it — and if you just want the toolchain without provisioning a VM,
the repo ships a dev container (it builds and
enforces egress; file/exec blocking needs a host booted with lsm=...,bpf):
# 1. one-time: enable BPF LSM (adds `lsm=...,bpf` to the kernel cmdline) + reboot
sudo ./scripts/enable-bpf-lsm.sh && sudo reboot
# 2. one-time: toolchain (rustup nightly + rust-src, bpf-linker)
./scripts/setup-vm.sh
# 3. build
cargo build --release # userspace + eBPF, via aya-build
# 4. check what your policy will REALLY do — no root, no eBPF, no target
./target/release/wardyn --dry-run --policy policies/strict.yaml
# 5. observe (no blocking) — watch an agent's whole subtree
sudo ./target/release/wardyn run -- bash
# 6. enforce — actually block policy violations
sudo ./target/release/wardyn --enforce run -- bash scripts/demo.shRenders a live TUI when attached to a terminal; pipe it (or pass --plain) for a
plain table. --policy <file>, --audit <file>, --denials <file> and
--overrides <file> override the defaults; wardyn --help lists the rest. The
watched agent is run as your non-root user by default (via
$SUDO_UID, so it can't disable its own warden); use --as-user uid[:gid] to
choose, or --keep-root to keep it as root.
In the TUI, q quits — and stops the agent with it. Wardyn's enforcement
lives in programs this process owns, so leaving the agent running after wardyn
exits would hand it the unsupervised shell the tool exists to prevent, silently,
at the moment you pressed a key. Under --enforce, a grants an exception for
the last denial (with a y/n confirm that names the true scope); it is stored for
the policy it was granted against — see Telling the agent.
Wardyn exits with the agent's own exit status.
policy.yaml — three rule lists (files, network, exec),
two containment keys (allow_paths, allow_ports — below), and a
default_action fallback for the lists. Actions: allow | warn | block.
Matching order differs per axis, and saying "first match wins" everywhere would
be wrong:
| Axis | Order |
|---|---|
files / exec |
first match wins |
network |
longest prefix wins (the kernel uses an LPM trie) |
network, rules naming a port: |
consulted first, whatever the address prefixes — see below |
files / exec under --enforce |
no order — the kernel holds a set of block keys, so an earlier allow does not exempt what a later block covers |
A network rule may name a port: and a proto: (tcp/udp), and a
rule that names more of them beats one that names fewer — whatever their address
prefixes. Rules are consulted in four tiers, most specific first:
| tier | example | beats |
|---|---|---|
| protocol and port | { port: 53, proto: udp, action: allow } |
everything below |
| port | { port: 25, action: block } |
address rules, at any prefix |
| protocol | { proto: udp, action: block } |
address rules, at any prefix |
| address | { cidr: "10.0.0.0/8", action: allow } |
the default |
So { port: 25, action: block } denies SMTP even to a /8 the policy allows in
full, and { proto: udp, action: block } denies UDP there too. Within a tier it
is longest-prefix as usual, so a specific relay can still be allowed back. A bare
port:/proto: with no cidr: covers both address families — a v4-only
reading would leave the same port open over IPv6.
Letting prefix length decide across dimensions instead would make
{ proto: udp, action: block } a /0 rule that any /8 allow outranks, so the
most useful protocol rule there is would quietly not mean what it says.
A
proto:rule is enforced by the kernel but not predicted in the feed: the connect tracepoint sees asockaddr, not a socket, so it has no protocol to match on. Where a policy makes the outcome depend on the transport, the observed row says so and does not claim a verdict; if the kernel denies, its own row reports it.--dry-runprints the same warning.
A file or exec rule is written with either match: (a glob over names) or
path: (one concrete object, pinned by identity) — never both. In the kernel a
match: glob keeps its last two literal segments: **/.aws/credentials
denies credentials under .aws, not every file called credentials. What a
glob says beyond that is dropped, and --dry-run names what was dropped. File rules also
take an access:, which picks the operation the rule covers across two axes:
access: |
matched when | hook |
|---|---|---|
any (default), read, write |
the file is opened | file_open |
create |
a name comes into existence | inode_create, inode_mkdir, inode_link, inode_symlink, and a rename's destination |
delete |
a name is removed | inode_unlink, inode_rmdir, and a rename's source |
all |
every one of the above | all of them |
The split exists because rm is not an open: file_open never fires for
unlink(2), so a rule that protects a secret's contents said nothing at all
about destroying it. rm -rf was never a read.
The default stays any, and any covers only opens. Widening it would mean
every block rule ever written silently started refusing rm the day wardyn was
updated — a change of meaning on the rules people re-read least. Ask for delete
(or all) where you mean it; policies/strict.yaml does.
wardyn --dry-run prints exactly which keys the kernel will hold, which rules are
flagged but never denied, which enforce more broadly than written, and which
allow rules the kernel's unordered set overrides. Unknown keys and unsupported
version: values are refused rather than ignored — a typo'd section used to
disable a whole rule class silently.
default_action: allow
files:
# `match:` — a glob over names. Covers files that do not exist yet.
- { match: "**/.env", action: block } # any file named .env
- { match: "**/.ssh/**", action: block } # anything up to 64 levels under a dir named .ssh
- { match: "/etc/shadow", action: block } # `shadow` under `etc` — not every `shadow`
# `path:` — one object, pinned by (dev, ino) at load. `mv` and `ln` do not
# shake it off. `~` is the AGENT's home; a bare name is relative to where
# wardyn was launched. `wardyn --dry-run` prints what each one resolved to.
- { path: "~/.ssh", action: block, access: all } # ...and no rm, no new files in it
- { path: ".env", action: block }
# `access:` picks which operation the rule covers (default: any = opens only).
- { match: "**/id_rsa", action: block, access: read }
- { match: "**/*.sqlite", action: block, access: delete }
- { match: "**", action: allow }
network: # cidr, or domain (re-resolved every 60s)
- { cidr: "127.0.0.0/8", action: allow }
- { domain: "github.com", action: allow }
- { port: 25, action: block } # never SMTP — beats any rule above
- { port: 53, proto: udp, action: allow } # ...but DNS over UDP is fine
- { cidr: "0.0.0.0/0", action: block } # deny all other egress
exec: # glob against the executable path
- { match: "**/nc", action: block } # netcat / reverse shells
- { match: "**", action: allow }Everything above is a blocklist — name what is forbidden, and anything the
policy forgot stays reachable. allow_paths: is the other shape, and it uses a
different kernel mechanism (Landlock) to do it:
allow_paths:
- { path: "/usr", rights: [read, exec] }
- { path: "/etc", rights: [read] }
- { path: ".", rights: [read, write, exec] } # the projectThe agent reaches those hierarchies and nothing else — not /home, not
another checkout, not a mounted drive. It is applied to the child before exec,
inherited by every descendant, and cannot be undone; unlike the eBPF half it
needs no privilege, so it holds even for a root agent.
The two compose: containment removes everything outside, and files:/exec:
deny specific objects inside what remains.
Egress has the same two shapes. network: is the blocklist — CIDRs, ports and
protocols, decided by eBPF, which is the only half that can see an address.
allow_ports: is the boundary:
allow_ports: [443, 53]The agent may connect to, or bind, exactly those TCP ports. Landlock again: no privilege needed, inherited by every descendant, and impossible to undo.
By port only — Landlock has no notion of an address, which is precisely why
this sits beside the eBPF hooks rather than replacing them. network: still
decides which hosts, inside the ports that remain.
Two things to know:
allow_ports: []means no TCP at all. Leaving the key out means the policy said nothing, and onlynetwork:applies. They are different statements.- Both directions are confined. Restricting only outbound would leave the agent free to listen and be connected to instead — the same egress, drawn the other way.
It needs Landlock ABI 4 (Linux 6.7+). On an older kernel wardyn refuses to start rather than run an agent the policy believes is confined to ports it is not.
Nothing above is denied by a rule — the policy has none for
those paths. They are denied because they are not in allow_paths:,
which is the difference between a blocklist and a boundary.
It is an allowlist, and that bites. Anything unlisted is denied, including what nobody thinks about: the agent's loader,
/dev/null, the script it was asked to run. A missing entry looks like a broken agent, not like a policy gap. Start frompolicies/contained.yamland runwardyn --dry-runfirst. If a granted path cannot be resolved, wardyn refuses to start rather than confining the agent out of something it was promised.
Ready-made presets live in policies/.
--format json writes one object per line on stdout — every event, allow rows
included, so a log shipper or a SIEM gets the baseline and does its own
filtering:
$ wardyn --enforce --format json run -- npm install | jq -c 'select(.enforced)'
{"schema_version":1,"ts":"…","pid":2140,"comm":"cat","event":"open",
"action":"block","enforced":true,"source":"observed","detail":"/app/.env",
"rule":"**/.env","matched_key":"name=.env","enforceable":true,"excepted":false}Two fields do the work. enforced is what the kernel actually did — count
denials with that, never with action == "block", because a warn and an
unenforceable block~ are both blocks that denied nothing. matched_key is
the kernel key the decision fired on, which is what to aggregate by: rule is
policy text and several rules can share a key.
The shape is a documented interface with a version on every record, not just a
header — see docs/EVENT_SCHEMA.md for the
compatibility rules and worked jq recipes.
A kernel denial reaches the agent as a bare EPERM — indistinguishable from an
ordinary permission error. Agents respond the way agents do: retry, reach for
sudo, or code around the failure. Half the loop was missing: you can see
everything the agent does, but the agent can't see you.
Under --enforce, wardyn spawns the target with WARDYN_DENIALS=<path> in its
environment: a per-run JSONL receipt whose first line explains the file (written
for an LLM to read) and every further line is one action the kernel actually
denied —
{"wardyn":"denial-receipt","version":1,"note":"Wardyn is a kernel-level policy warden supervising this process tree. ... Do not retry or work around a denial — report the `rule` to the human operator ...","policy":"9 file rule(s), 8 network rule(s), 5 exec rule(s), default=allow","started":"2026-07-14T10:11:58.102Z","target":"claude refactor the auth module"}
{"ts":"2026-07-14T10:12:03.412Z","pid":40250,"comm":"node","event":"open","detail":"/home/me/project/.env","rule":"**/.env"}
{"ts":"2026-07-14T10:12:07.011Z","pid":40244,"comm":"curl","event":"connect","detail":"185.220.101.7:443","rule":"cidr:0.0.0.0/0"}Tell your agent about it once, in its standing instructions (CLAUDE.md,
AGENTS.md, a system prompt):
If a command fails with a permission or network error and the environment variable
WARDYN_DENIALSis set, read that file. If a record matches the failure, a security policy denied the action: do not retry or work around it — report theruleto the user and continue with the rest of the task.
The receipt is advisory output, never input: the watched tree can read (or
even scribble on) it, but enforcement lives in kernel maps and root-owned policy
it cannot reach. Only real kernel denials are receipted — warns and
observe-only block~ flags never appear. --denials <path> overrides the
default location (/tmp/wardyn-denials-<pid>.jsonl).
The loop closes from your side too: in the enforcing TUI, a offers to allow
the most recent denial. The confirm prompt states the real blast radius —
"ALL egress to 1.1.1.1", "ANY file named .env" — because the kernel matches
bare names and addresses, and wardyn won't pretend an exception is narrower
than it is. On y the kernel map and the feed's mirror update together, and an
exception record lands in the agent's receipt: you may retry. Deny →
report → approve → retry, without restarting the agent.
An approval outlives the run. It is stored — default
/var/lib/wardyn/overrides.yaml, outside the watched tree's reach — under a
fingerprint of the policy text, for --override-ttl days (default 30). The next
run of the same policy starts with it in force; edit the rules and every
approval granted against the old text is out of force until the text is put
back, because an exception to one set of rules must not widen the next.
--override-ttl 0 keeps exceptions to the run.
One syscall, followed down. The agent asks for
~/.ssh/id_ed25519. The pulse is that single openat(): past the tracepoint
that can only watch, through the WATCHED check, into lsm/file_open, where
BLOCK_INODES hits and the call returns -EPERM before a descriptor exists —
then the receipt goes back up to the agent. It is one of eleven traces in
docs/inside-the-syscall.html, a self-contained page that
also plays the startup order, the rule→kernel-key compiler and the limits. Save the file and open
it in a browser; every box in the diagram is clickable.
wardyn run -- <agent>
│ hooks attached, maps filled, WATCHED seeded — only then: drop
│ privileges, apply Landlock (allow_paths · allow_ports), exec
▼
┌──────────────── watched process tree · children adopted in-kernel ─────────────────┐
│ exec · open · create · delete · rename · link · connect │
└──────────────────────────────────────────┬─────────────────────────────────────────┘
▼
┌────────────────────────────────────────────────────────────────────────────────────┐
│ KERNEL │
│ exec open create · delete egress │
│ observe tp/execve tp/openat — tp/connect │
│ tp/execveat tp/openat2 tp/sendto │
│ enforce LSM bprm_check LSM file_open LSM inode_unlink cgroup/connect4·6 │
│ → -EPERM → -EPERM rmdir · rename cgroup/sendmsg4·6 │
│ create · mkdir → deny │
│ link · symlink │
│ → -EPERM │
│ │
│ boundary Landlock — allow_paths (filesystem) · allow_ports (TCP) → -EACCES │
│ maps WATCHED · BLOCK_{NAMES,DIRS,PAIRS,INODES} · NET_RULES (4 LPM tiers) │
│ ring every observed call · every kernel denial, with the key it hit │
└────────────────────────────────────────────────────────────────────────────────────┘
▲ keys events ▼
┌────────────────────────────────────────────────────────────────────────────────────┐
│ USERSPACE │
│ policy.yaml + stored approvals ─▶ kernel keys (--dry-run prints them) │
│ events ─▶ TUI · --plain · --format json ─▶ JSONL audit log │
│ ─▶ WARDYN_DENIALS: the agent's receipt, kernel denials only │
└────────────────────────────────────────────────────────────────────────────────────┘
- Order — the hooks are attached and the maps filled before the agent
exists, and
WATCHEDis seeded so the fork hook adopts the child insideclone(). Only then, in the child: drop to the invoking user,no_new_privs, Landlock,exec. There is no moment it runs unwatched. - Observation — tracepoints on
execve/execveat,openat/openat2andconnect/sendtostream a structured event per action into a ring buffer; userspace evaluates the policy, colours the feed, and writes the audit log. A tracepoint can watch; it cannot deny. - Scoping —
WATCHEDis keyed by the kernel's own tgid; asched_process_forkhook adopts children in-kernel, so the whole subtree is followed race-free. Thread ids are evicted as their threads die, and a failed insert is counted — a full watch set would otherwise mean new children running unwatched. - Enforcement — separate programs deny inline:
cgroup/connect4·6+sendmsg4·6return deny for blocked egress (TCP connect and UDP sendmsg, IPv4 & IPv6); BPF-LSMfile_open/bprm_check_securityreturn-EPERMfor blocked reads / execs, andinode_unlink/rmdir/rename/create/mkdir/link/symlinkdo the same for thedelete/createaxis. All gated onWATCHED+ anenforceflag. - Containment —
allow_paths:andallow_ports:are Landlock rulesets the child applies to itself beforeexec: no eBPF, no privilege, inherited by every descendant, irreversible. Their denials are-EACCES, and they are counted apart, because Landlock keeps no counter for the end-of-run cross-check. - Reporting — each deciding hook emits its own event naming the key it matched, so the feed, the audit log and the receipt state what the kernel did rather than what userspace guessed from the observed path.
Full design, hook map, and the eBPF-verifier war stories are in ARCHITECTURE.md.
- Linux with BTF (
/sys/kernel/btf/vmlinux) — kernel ≥ 5.8. - cgroup v2 (for network blocking).
- BPF LSM (
CONFIG_BPF_LSM=y+lsm=...,bpfon the cmdline) for file/exec blocking. - Root (to load/attach eBPF).
- Built with Rust nightly +
bpf-linker(aya). - Works from inside pid namespaces (containers, WSL2 distros): wardyn learns its
kernel-view pid via an in-kernel handshake and says so when it differs. If the
handshake cannot complete there,
runrefuses to start rather than watch a pid that means something else to the kernel.
The LSM file/exec matcher reads a few
struct file,dentryandinodefields by offset. Wardyn resolves those offsets at runtime from the kernel's own BTF (/sys/kernel/btf/vmlinux), so it adapts to the running kernel instead of being pinned to one layout; if resolution fails it falls back to the built-in kernel-6.8 offsets, names the reason, and demotes file/exec rows toblock~unless the running kernel really is 6.8. (True CO-RE — compiler-emitted BTF relocations — is not available for the Rust BPF target; this is arustc/LLVM limitation, not an aya one, so runtime resolution is the portable answer.)scripts/kernel-offsets.shremains a manual cross-check.The resolver descends into anonymous struct members, which is not a detail: Linux 6.13 moved
f_pathinside an anonymous union instruct file, and a resolver that only inspected direct members found nothing, fell back to the 6.8 offsets, read the wrong words, and failed open — with the feed still looking healthy. Every kernel from 6.13 on was silently unenforced for files and execs until this was fixed;cargo test -p wardyn --bin wardyn btf::now checks resolution against the kernel the tests are running on.
- M1 — Observe: live tree of exec/open/connect, scoped to a subtree.
- M2 — Policy:
policy.yaml(glob + CIDR), allow/warn/block, JSONL audit. - M3 — Block: deny egress (cgroup — TCP + UDP, IPv4 + IPv6) + secret reads & blocked execs (LSM).
- M4 — Ship: demo GIF, devcontainer, packaging. (IPv6/UDP egress ✓,
presets ✓,
--dry-runpolicy checker ✓, portable policy tests on Linux/macOS/ Windows ✓, dev container ✓, static musl release builds ✓, demo GIF ✓ — recorded on a BPF-LSM kernel, so every⛔BLOCKrow in it is a real-EPERM; the tapes are checked in, seedocs/RECORDING.md) - M5 — Agent feedback: the agent learns what was denied and why, instead
of flailing at a bare
EPERM. (denial receipts ✓, approve-once exceptions from the TUI ✓, kernel-reported denials ✓, persistent overrides kept outside the watched tree's reach and bound to the policy fingerprint they were granted against ✓) - M6 — Match on identity, not names: (
(dev, ino)keying for files, directories and executables ✓, read/write axis ✓, create/delete axis ✓,port:in network rules ✓,proto:(tcp/udp) alongside it ✓, offsets resolved from the running kernel's BTF ✓, e2e proof that rename/hard-link/copy no longer defeat a rule — including a control run showing they still do without it ✓, and that a plainblockrule still permitsrm, so no existing policy changed meaning ✓) Copying a blocked binary to a new name still runs it — a copy is a different object with a different name, and unlike a secret there is no read to deny; seeSECURITY.md.
All six are done. What is not done is listed rather than implied:
docs/AUDIT.md carries every finding with a status
(98 closed · 10 deliberately open · 4 open · 1 rejected), and the
deliberately-open ones — io_uring, AF_UNIX and loopback delegation, raw-socket
egress, the dentry-name read racing a rename — are the limits
SECURITY.md states, not gaps waiting for a milestone.
Startup is ~0.9 s observing and ~2.2 s under --enforce — loading and
attaching ~21 eBPF programs and parsing kernel BTF. That is the number that
should change what you do with it: wardyn is for supervising a session, not
for wrapping individual commands in a loop.
After that it is roughly +15 µs per file open observing, +17 µs enforcing. Enforcing adds little over observing, because the in-kernel matcher is a few hash lookups while shipping the event to userspace is the bulk of it.
Measured on kernel 6.18 x86_64 with the shipped policy — see
docs/PERFORMANCE.md for the method, the caveats, and
scripts/bench.sh to run it on your own kernel.
Contributions are welcome — see CONTRIBUTING.md for the dev
setup (nightly + bpf-linker, Linux/VM) and the checks CI runs. Please be kind;
we follow a Code of Conduct.
GOVERNANCE.md says who decides, what gets accepted, and — more usefully before you depend on this — what the project does not promise. It is maintained by one person, and says so.
Wardyn runs as root and loads eBPF into the kernel. Found a vulnerability? Please report it privately — see SECURITY.md, not the public issue tracker. The threat model and known limitations are documented there too.
An independent, adversarially-verified audit of the whole codebase — every gap,
escape, and honesty caveat, ranked by severity — lives in
docs/AUDIT.md; an honest comparison against sandboxes,
Landlock, and Tetragon/Tracee is in docs/COMPARISON.md.
Read both before relying on Wardyn as anything more than a defence-in-depth layer.
Licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later), with per-file SPDX identifiers.
Copyright (C) 2026 Kadir Can Yildirim.
This is a strong copyleft licence: you may use, study, modify and redistribute Wardyn, but any distributed derivative — including one offered to others over a network — must be released under the AGPL and make its complete source available. You must preserve the copyright and licence notices.
wardyn-ebpf and wardyn-common are GPL-2.0-only OR AGPL-3.0-or-later.
Everything else is AGPL alone.
That is not a softening; it is what makes the object honest. Those two crates
compile into the eBPF object the kernel loads, and the kernel decides what it
accepts: a BPF LSM program must be GPL-compatible, and bpf_probe_read_kernel
is a GPL-only helper. The object declares GPL in its ELF license section,
which to the kernel means GPLv2 — and AGPL-3.0 is not on the kernel's
license_is_gpl_compatible() list, so declaring it truthfully would fail the
load outright.
An AGPL-only crate shipping an object under a GPLv2 declaration was granting terms its source did not. The GPL-2.0 arm makes the declaration true; the AGPL arm keeps the crates usable exactly as before. No right is removed from anyone.
Userspace — wardyn and wardyn-policy, which is the tool you run — is
unchanged: AGPL-3.0-or-later.
Unless you explicitly state otherwise, any contribution you intentionally submit for inclusion in the work shall be licensed as above, without any additional terms or conditions.






