FAN_OPEN_EXEC_PERM hands us the open and waits for an answer, so a
binary can be refused before it runs. inotify could only report what
had already happened.
Verified end to end as root against a dedicated tmpfs (examples/
gate-smoke.rs, three phases):
benign binary ran 7.2 ms
EICAR binary blocked 1.8 ms never executed
scanner stalled 5 s ran 1.6 s watchdog rescued 3 events
The third phase is the one that matters. A gate that can hold a process
forever is a machine-wedging bug wearing a feature's clothes, so the
watchdog answers ALLOW for anything unanswered past DEADLINE and counts
it. A missed detection is a bad day; a frozen machine ends the product.
Two things this cost, both worth recording:
* Scanning by re-opening the path deadlocks the daemon against itself.
The open() lands on the watched mount and queues a permission event
behind the one we are currently answering, and we cannot answer that
one until we finish this one. Allowing our own pid does not help —
the thread never gets back to the queue to apply the rule. The gate
reads through the descriptor the kernel already handed it, with
pread so the gated process still sees its own file offset. This is
what hung the first smoke run.
* The watchdog can only rescue events it has been told about, and it
learns of them when the queue is drained. Scanning on the draining
thread makes every event behind a slow scan invisible to the
deadline. Reader and workers are therefore separate threads: the
reader never blocks on a scan, so every event is registered within
microseconds of arriving.
Also:
- ScanEngine::scan_bytes — the seam the gate needs, since it must never
scan by path. Engines that cannot do it return None and simply are
not usable behind the gate.
- Settings gain exec_gate and exec_gate_paths, defaulting to OFF. It
needs CAP_SYS_ADMIN and a root-filesystem mark holds every process on
the box; that is not a default to ship before Phase 2 soak testing.
- ABI constants are defined locally rather than taken from libc, so a
version bump cannot quietly change what we ask the kernel for.
78 tests pass, up from 57.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old engine shelled out to clamscan for every scan, and clamscan
reloads a 169 MB signature database on every invocation. Measured on a
68-byte EICAR file: 6.5 seconds and ~1.5 GB RSS — paid once per file,
and realtime.rs called it once per inotify event.
Replaces it with HoundEngine: yara-x compiled once at daemon start,
held in memory, one scanner reused across a whole walk, plus a verdict
cache keyed on (dev, ino, mtime, size) so an unchanged file that has
been seen before never reaches the matcher.
Measured after, same machine, same EICAR file:
single file 6.5 s -> 4 ms
400 files cold -- -> 9 ms
400 files warm -- -> 5 ms
Also here:
- rules.rs: hot-swappable rule store. Built-in pack is embedded so a
fresh install detects something before it has ever reached the
network; on-disk packs load from $HOUNDD_RULES_DIR, /var/lib/hound
or the XDG data dir. Reload swaps an Arc, so in-flight scans are
never torn out from under.
- cache.rs: bounded FIFO verdict cache. Any of the four key fields
changing means rescan, so edits, truncates and replace-by-rename all
correctly miss.
- The goodware gate: every rule is scanned against all of /usr/bin,
/bin and /usr/sbin in CI, and a single hit fails the build. It has
already earned its keep — it caught a reverse-shell rule that matched
/usr/bin/sudo, which is now removed rather than tuned. A rule that
quarantines sudo is worse than no rule at all.
- ScanEngine is Send + Sync and selection stays per-call, so
HOUNDD_ENGINE=clamav still reaches the legacy path for comparison.
- ScanResult.skipped reports files passed over for size instead of
quietly counting them as clean.
- Settings gain theme (auto/light/dark), tray_icon_style (color/mono),
close_to_tray and confirm_quit, normalised daemon-side because
clients are not trusted to send a theme we can render.
57 tests pass, up from 29.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>