Completes Phase 1. The gate now asks for FAN_CLOSE_WRITE alongside the
permission events, so a threat written to disk is quarantined and a
threat being executed is refused — one mechanism, one mark, no
watch-descriptor ceiling and no blind spots outside a configured list.
Verified live. The nicest evidence is an error message:
$ chmod +x /tmp/hound-live/malware.sh
chmod: cannot access '/tmp/hound-live/malware.sh': No such file or directory
Hound had already quarantined it. `hound quarantine list` shows the
entry, the clean binary beside it still runs, and CapPrm/CapEff/CapBnd
read 000000000020000e.
Three bugs, each of which looked like working code:
* A file descriptor number is not an identity. The kernel allocates an
fd per event and recycles the number the moment we close it, so one
write arrives as FAN_OPEN_PERM on fd 6 and then FAN_CLOSE_WRITE on fd
6 again. Idempotency keyed on the fd treated the second as a duplicate
of the first and dropped it — detection ran, matched EICAR, and threw
the result away. Events now carry a monotonic seq that is never reused.
* rename(2) fails EXDEV across filesystems, and for quarantine that is
the common case rather than the exotic one: the vault is under
/var/lib while threats land on /home, in a tmpfs, on a USB stick or
in a container overlay. Quarantine now falls back to copy-then-unlink,
unlinking only once the copy is safely down, and seals the stored file
at 0600 with every execute bit cleared.
* The capability set was too small to do the job. CAP_DAC_READ_SEARCH
lets us read a threat but not unlink it, so quarantine failed EACCES
as root. The set is now four capabilities — SYS_ADMIN, DAC_READ_SEARCH,
DAC_OVERRIDE, FOWNER. DAC_OVERRIDE is close to "write anywhere" and
that is worth being honest about; an antivirus that quarantines cannot
avoid it, because the threat is by definition in a directory somebody
else owns. What the reduction still buys is what it excludes, and
there is a test asserting SYS_MODULE, SYS_BOOT, SYS_PTRACE, NET_ADMIN,
NET_RAW, AUDIT_CONTROL and SETUID never creep back in. Narrowing
further means a separate privileged helper for quarantine.
realtime.rs is now documented as the unprivileged fallback and does not
start when the gate is armed — running both would scan everything twice
and quarantine the same file from two threads.
99 tests pass. HOUNDD_GATE_DEBUG=1 dumps every event and decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>