Real-time Linux filesystem change monitoring with process attribution.
fsmon is a real-time Linux filesystem change monitor powered by fanotify. It watches files and directories, captures every event (create, modify, delete, move, attribute change, etc.), and attributes each change back to the process that caused it — including the PID, command name, user, parent PID, thread group ID, and optional full process ancestry chain.
Process tracking is event-driven : the kernel's cn_proc event stream maintains the topology with no polling and no recursive /proc scanning; per-CPU message sequences quantify lost events. A one-shot /proc scan serves only as the bootstrap baseline.
Unlike standard file monitoring tools that only report which file changed, fsmon adds process attribution — it identifies which process caused each change. This makes it easier to debug unexpected file modifications in multi-process environments. For system administrators and developers who need to track down the source of filesystem changes, fsmon provides deeper insights that traditional tools cannot offer.
This crate is Linux-only and will fail to compile on other platforms.
Lightweight high-performance file change tracking tool
Usage: fsmon <COMMAND>
Commands:
daemon Run the fsmon daemon (needs CAP_SYS_ADMIN for pid attribution) [alias: d]
add Add a path to the monitoring list [alias: a]
remove Remove one or more paths from the monitoring list [alias: r]
monitored List all monitored paths with their configuration [alias: m]
query Query historical file change events from log files [alias: q]
clean Clean historical log files, retain by time or size [alias: cl]
changes Show the most recent event per path (deduplicated changes) [alias: ch]
init Create the config file (directories created on first use) [alias: i]
cd Open a subshell in the monitored path or log directory
health Query daemon health status [alias: h]
help Print this message or the help of the given subcommand(s)
Options:
-v, --version Print version
-h, --help Print help (see more with '--help')
Man pages and shell completion scripts (bash, fish, zsh, nushell) can be generated with:
fsmon init -c
Use fsmon --help or man fsmon for detailed documentation.
# Install
cargo install fsmon
# Install the hardened systemd service: runs as your user, grants only
# CAP_SYS_ADMIN, and filters the syscall surface
sudo fsmon init --service
sudo systemctl enable --now fsmon
# Or run it manually for debugging
sudo fsmon daemon
# In another terminal, add a path to monitor
fsmon add _global --path /var/www -r
# Query events
fsmon query _global | jq 'select(.cmd == "nginx")'fsmon needs CAP_SYS_ADMIN for exactly one thing: creating a privileged
fanotify group. The kernel marks a group created without that capability as
FANOTIFY_UNPRIV, and then blanks metadata.pid for every event caused by
another process (fs/notify/fanotify/fanotify_user.c) — process attribution,
fsmon's entire point, silently becomes pid: 0.
Nothing else needs privilege: marking, reading events, FID→path resolution, process tracking and logging all work unprivileged.
The daemon is built so that this capability is confined:
- At startup it forks a tiny fanotify factory subprocess that inherits
CAP_SYS_ADMIN. Its whole input is(dirfd[, fan_fd], flags, mask)— no path strings, no JSON, no event stream — and it runs under a seccomp whitelist of eight syscalls (fanotify_init,fanotify_mark,recvmsg,sendmsg,read,write,close,exit_group). - The main daemon then drops all capabilities (
CapEff=0,PR_SET_NO_NEW_PRIVS). The privileged fanotify groups keep reporting real pids because the kernel storesFANOTIFY_UNPRIVon the group object. - The privilege never exists as a file on disk, so no other local user can
obtain it (unlike a
setcaphelper binary).
fsmon init --service writes a unit that runs the daemon as your user with
AmbientCapabilities=CAP_SYS_ADMIN, CapabilityBoundingSet=CAP_SYS_ADMIN,
NoNewPrivileges=yes, SystemCallFilter=@system-service fanotify_init fanotify_mark, and write access limited to the store, log and runtime
directories. Note that fanotify_init/fanotify_mark are not part of
systemd's @system-service set, so they must be listed explicitly.
fsmon deliberately does not request CAP_DAC_READ_SEARCH. It would only
serve the open_by_handle_at path-resolution fallback, which in practice is
never reached (directory handles are pre-cached via the unprivileged
name_to_handle_at), and delegating it would create a real file-read oracle.
Instead the resolver runs with its syscall fallback turned off, so the fallback
fails with zero syscalls; fsmon health exposes the miss count as
dir_cache_misses.
If you start the daemon without CAP_SYS_ADMIN it refuses to run rather than
silently recording pid: 0. Set FSMON_ALLOW_UNPRIVILEGED=1 to accept the
degraded mode deliberately; fsmon health then reports "unprivileged": true.
Requires Rust toolchain (tested with rustc 1.78.0).
git clone https://github.com/lenitain/fsmon.git
cd fsmon
cargo build --releasefsmon tracks processes via the kernel's cn_proc connector (fork/exec/exit events). The kernel only emits a COMM event when a process renames itself via prctl(PR_SET_NAME) — never on exec (proc_comm_connector is only called from the PR_SET_NAME branch in kernel/sys.c). This is kernel design, not a bug in fsmon or its dependencies.
Consequences for short-lived processes (spawn → exec → exit in <1 ms, e.g. touch):
- File events are never missed; pid/tgid/ppid/chain are complete.
- The
comm/cmdfields of the recorded event are empty — the process is usually already gone by the time the event is processed, so/proccannot be read either. - A
cmd=filter group does not match such processes.
Long-lived processes are unaffected: their comm is captured by the bootstrap /proc scan and refreshed via rename events.