Skip to content

Latest commit

 

History

105 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

🐕 Wardyn

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.

CI license built with Rust + aya eBPF status

Wardyn blocking an agent from reading .env, deleting ~/.ssh, and dialing an unknown IP

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.

Why

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.

What it does

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.yamlallow / 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.

Does it hold?

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

Eight bypass attempts against a secret file, all stopped; then the same attacks against name rules, where the hard link gets through

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.

Fifteen attempts to disable wardyn from inside the watched process; fourteen refused

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.

450 processes and 40,000 opens; the secret stays denied while 13,000 events are dropped and reported

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.

git, gcc, make and python3 running normally under the same enforcing policy

4 · real work. A sandbox that denies everything passes every attack test and gets switched off on the second day.

Quickstart

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.sh

Renders 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

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 a sockaddr, 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-run prints 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 }

Containment: allow_paths:

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 project

The 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.

Containment: allow_ports:

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 only network: 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.

An agent confined to one project directory: reads inside it succeed, everything outside is BLOCK

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 from policies/contained.yaml and run wardyn --dry-run first. 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/.

Piping it somewhere

--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.

Telling the agent

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_DENIALS is set, read that file. If a record matches the failure, a security policy denied the action: do not retry or work around it — report the rule to 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.

How it works

One openat() followed from the agent into the kernel: tracepoint, WATCHED map, BPF-LSM file_open, -EPERM, and the receipt coming back up

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 WATCHED is seeded so the fork hook adopts the child inside clone(). 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/openat2 and connect/sendto stream 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.
  • ScopingWATCHED is keyed by the kernel's own tgid; a sched_process_fork hook 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·6 return deny for blocked egress (TCP connect and UDP sendmsg, IPv4 & IPv6); BPF-LSM file_open / bprm_check_security return -EPERM for blocked reads / execs, and inode_unlink / rmdir / rename / create / mkdir / link / symlink do the same for the delete / create axis. All gated on WATCHED + an enforce flag.
  • Containmentallow_paths: and allow_ports: are Landlock rulesets the child applies to itself before exec: 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.

Requirements

  • Linux with BTF (/sys/kernel/btf/vmlinux) — kernel ≥ 5.8.
  • cgroup v2 (for network blocking).
  • BPF LSM (CONFIG_BPF_LSM=y + lsm=...,bpf on 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, run refuses to start rather than watch a pid that means something else to the kernel.

The LSM file/exec matcher reads a few struct file, dentry and inode fields 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 to block~ 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 a rustc/LLVM limitation, not an aya one, so runtime resolution is the portable answer.) scripts/kernel-offsets.sh remains a manual cross-check.

The resolver descends into anonymous struct members, which is not a detail: Linux 6.13 moved f_path inside an anonymous union in struct 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.

Roadmap

  • 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-run policy checker ✓, portable policy tests on Linux/macOS/ Windows ✓, dev container ✓, static musl release builds ✓, demo GIF ✓ — recorded on a BPF-LSM kernel, so every ⛔BLOCK row in it is a real -EPERM; the tapes are checked in, see docs/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 plain block rule still permits rm, 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; see SECURITY.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.

What it costs

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.

Contributing

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.

Security

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.

License

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.

The kernel-side crates are dual-licensed

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.

About

A kernel-level warden for AI coding agents — eBPF watches every file, exec, and network connection an agent makes, and blocks policy violations in real time.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages