Fanotify
[!WARNING] Permission events block the process that opened a file until userspace responds. A missing response or filesystem access from the handler can deadlock a process or the machine.
Fanotify::Notifier#each_eventfails open and closes event descriptors, but handlers must still follow the safety rules below.
Ruby 3.2+ C bindings for Linux fanotify(7). The gem supports ordinary
notifications, permission decisions, FID/name records, PIDFD records, rename
records, filesystem errors, and queue-overflow detection.
Requirements
- Linux 5.1+ and a kernel built with
CONFIG_FANOTIFY. CAP_SYS_ADMINfor mount/filesystem marks and all permission events.- Linux 5.9+ for FID reporting, 5.13+ for unprivileged FID notification groups, 5.15+ for PIDFD reports, 5.17+ for rename/target FID reports, and 6.14+ for experimental pre-access events.
- A C compiler and Linux UAPI headers when installing the gem.
Fanotify.supported? checks whether the syscall exists. It does not prove that the
current process has permission for a particular class or mark.
Installation
gem "fanotify"
Then run bundle install. The native extension is built during installation.
Notifications
The high-level API uses the unprivileged-compatible FID/name mode:
require "fanotify"
Fanotify.watch("/data", events: %i[create delete modify]) do |event|
puts "#{event.mask.inspect} #{event.name}"
end
Use Notifier for explicit classes and marks:
Fanotify::Notifier.open(class: :notif, report: %i[fid dfid_name]) do |notifier|
notifier.mark(:add, "/data", events: %i[create delete event_on_child], only_dir: true)
notifier.each_event { |event| puts event.name }
end
Event#fid, #dfid, #old_dfid, and #new_dfid are Fanotify::FileHandle
values. Handles are comparable and printable as hex, but v1 does not open them with
open_by_handle_at(2).
Linux 6.14 pre-access events expose the requested byte range as
Event#range_offset and Event#range_count; see examples/lazy_fetch.rb.
Permission decisions
Permission marks require CAP_SYS_ADMIN:
Fanotify::Notifier.open(class: :content, response_timeout: 5.0) do |notifier|
notifier.exclude_self!
notifier.mark(:add, "/data", events: %i[open_perm event_on_child], only_dir: true)
notifier.each_event do |event|
event.deny! if event.path&.end_with?(".secret")
# No explicit decision means allow! when the block exits.
end
end
The default behavior is fail-open:
- A permission event without
allow!ordeny!is allowed after the block. - A handler exception allows the event before the same exception is raised again.
defer!registers an asynchronous decision. The default watchdog allows it after five seconds;response_timeout: nildisables the deadline, while GC fail-open cleanup remains active.pending_eventsexposes live deferred events.closeallows an unanswered event before closing its descriptors.- If a response cannot be written, the notifier closes the whole group to release every process still waiting on it.
closeinterrupts threads blocked inread_eventswithFanotify::Error.
on_error: :deny changes only the handler-exception decision. It is intentionally
opt-in because application bugs then deny access.
Handler safety
- Load code, resolve configuration, and open log destinations before adding a
permission mark. Do not call
require, open logs, or lazily load files in a handler. exclude_self!filters self-generated events already read by the notifier. It cannot rescue a handler that blocks itself while opening another marked file on the same thread.- Inspect
event.file, which duplicates the kernel-opened descriptor and is closed with the event. Do not reopenevent.pathfor a security decision. event.pathcomes from/proc/self/fd/Nand is informational. Reopening it has a TOCTOU race. Deleted paths retain the kernel's" (deleted)"suffix.- Keep handlers bounded. The kernel has no response timeout; the gem watchdog covers only events explicitly deferred through this notifier.
Unsupported and restricted environments
- macOS, BSD, and Windows:
require "fanotify"succeeds andsupported?is false; constructing a notifier raisesFanotify::UnsupportedError. - Docker/Podman containers: permission events and mount/filesystem marks normally fail
without
CAP_SYS_ADMIN; Docker Desktop also runs inside a Linux VM. - GitHub-hosted runners: unit tests work normally, but system tests need the included virtme-ng VM runner.
- WSL2: availability depends on its kernel configuration and the capabilities granted to the process.
- Older Linux kernels accept only the flags available in that kernel; unsupported new
flag combinations fail with the kernel's
Errno::*exception.
Development
bundle install
bundle exec rake test:unit
bundle exec rake rbs
sudo bundle exec rake test:system # Linux with CAP_SYS_ADMIN
The system suite has hard timeouts and covers notification, allow/deny, exception fail-open, deferred timeout, FID names, unprivileged mode, queue overflow, and 10,000 permission events without fd growth.
Run the same suite in a kernel VM with:
tools/vm/run.sh # Linux 6.12
KVER=6.1 tools/vm/run.sh
bundle exec rake gen:constants regenerates the Ruby constant bindings. Values are
still resolved from the target system's UAPI headers at extension build time.
Reference throughput for 10,000 distinct FAN_OPEN FID events was 266,374 events/s
on Ruby 3.4.10, Linux 6.8 arm64 in a Docker Desktop VM. Run
bundle exec ruby benchmark/events.rb on the deployment host; handler work and event
shape dominate real throughput.
See examples/ for audit logging, malware scanning, and experimental pre-access programs.