Every pack in the published feed was dated 2026.08.21 because that is
when I last ran the ingest by hand. The client-side update machinery
shipped in 0.1.2 works well against a source that never moves, which
means it would have kept every installation confidently green on data
that aged a day for every day that passed — and the staleness warnings
added in the same release would have started telling users the truth
about a problem we caused.
tools/refresh-definitions.sh runs the steps that already existed
(ingest-osv.py, build-pack, index.json) with the properties a published
feed needs:
- Never publishes an empty feed. If every pack fails to build it
exits non-zero and leaves the previous one live. An agent that
installed an empty feed would report a clean machine with no
indicators loaded, which is worse than one keeping yesterday's.
- Packs are written before index.json, and each lands via rename.
The index is what tells an agent a pack exists, so writing it first
would advertise files that are not there yet, and a rename means a
fetch mid-run never sees a half-written pack.
- Old packs stay on disk; the index advertises only the newest per
ecosystem. An agent that has been offline for a while still has a
URL that resolves.
- One ecosystem failing does not cost the others.
The first dry run built a pack called stage-bfsqqa-2026.08.21.pack:
the staging directory was inside the work directory, and the loop
treats every directory in there as an ecosystem. Staging now lives
outside it, and directory names are filtered as well.
The timer runs at 05:20 UTC with Persistent=true, so a builder that was
off does a catch-up rather than silently skipping a day — that being
the exact failure that produces a stale feed nobody notices. The
service runs as the publishing user rather than root: it needs the
signing key and write access to one directory, and a build pipeline
running as root to write a web directory is a bigger target than the
thing it protects.
Verified: the timer is enabled, a real run republished all six packs
and 235,577 indicators, and a client installed from the result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A clean laptop reported 988 critical rootkit findings; this server
3786, PID 1 among them. Every one was false, and the cause was our own
systemd hardening.
ProtectProc=invisible hides processes the daemon does not own from its
view of /proc, while kill(pid, 0) keeps answering truthfully because it
is a syscall and not a filesystem lookup. The hidden-process check
compares exactly those two sources, so with that setting every process
on the machine looked concealed. Enumerating processes is this daemon's
job, so it needs the default view.
Removing the setting is not enough on its own — hidepid= on the /proc
mount produces the same blindness and we do not control that. So the
detector now recognises when it cannot see:
- PID 1 is the control. It always exists and nothing hides init; a
rootkit that did would break the machine it is living on. If PID 1
answers kill(1, 0) but is absent from the listing, we are blind and
say so as info rather than crying rootkit.
- A plausibility ceiling of 32. Hiding a handful of processes is the
entire point of a rootkit; hundreds means a broken observer. An
antivirus that reports a critical rootkit finding on every clean
machine teaches people to ignore the one time it is real.
Also in this change, from testing on a real desktop:
- Closing the window hides it to the tray instead of exiting, with a
one-time notification so it does not read as a crash. Quit lives
only in the tray menu and confirms first. The settings already had
close_to_tray and confirm_quit fields wired to nothing; they are
honoured now rather than hardcoded.
- The tray menu and Scan Home sent the literal string "~". A shell
would have expanded it, nothing here did, so the daemon was asked
to scan a directory of that name. It failed silently until the
per-peer readability check made it audible.
- Administrative actions elevate through polkit instead of telling
people to open a terminal. The app tries unprivileged first and
only on a privilege refusal runs `pkexec hound admin-rpc`, which
forwards one request as root. auth_admin_keep, because prompting on
every settings toggle trains people to authenticate without reading
the prompt. This grants what `sudo hound` already grants to people
who could already run sudo — a transport, not a new privilege.
- `hound settings exec-gate on|off` now exists. The install script,
the AppImage banner, the rpm spec, the AUR install file and
llms.txt all told users to run `hound settings set exec_gate true`.
There was no `set` subcommand and no way to enable the execution
gate from the CLI at all: the flagship paid feature was unreachable
and the first thing a new user was told to type returned an error.
A test now asserts every documented command parses.
- `settings show` displays the exec gate state, and no longer prints
its own header twice.
- The CLI help still described ClamAV, which has not been the engine
for some time. So did the socket permission error, which now
explains the `hound` group and the log-out-and-back-in it needs.
368 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The start-menu entry ran `hound` with Terminal=true — the CLI, which
printed help and exited. No GUI binary had ever been built or packaged.
Four separate faults were stacked behind that report:
1. build-deb.sh now builds and ships hound-gui, and writes a .desktop
entry only when that binary exists. A launcher for software that is
not there is worse than no launcher.
2. Tray icons were loaded from a relative "icons/" path, which resolves
only from the build tree. Installed to /usr/bin the setup hook failed
and Tauri panicked before a window appeared. They are include_bytes!
now — four ~1 KB PNGs that can no longer be missing.
3. The front-end never ran at all. app.js opened with a bare module
specifier ("@tauri-apps/api/core") and there is no bundler, so the
webview could not resolve it and the script silently failed to parse.
The window rendered its static HTML forever, which looks exactly like
a daemon that never answered. withGlobalTauri + window.__TAURI__.
4. build-deb.sh ran the Tauri build as `>/dev/null 2>&1 || true`, so a
config error scrolled past unseen and the package shipped the
PREVIOUS binary. Two fixes appeared to do nothing. That step is no
longer silenced or tolerant of failure, and the build fails outright
on a bare import in gui/dist/*.js.
Guards, because each of these failed quietly: index.html flips to an
interface-error message if app.js never sets a boot flag within 5s. An
antivirus showing "Protected - your system looks healthy" while its own
front-end is dead is the worst failure mode there is.
Then the window came up and could not reach the daemon: the socket was
0700 root:root. Widening it needed more than a chmod, because
quarantine.restore writes files back out as root — handing that to a
desktop group would hand out root. So the daemon now checks SO_PEERCRED
per method (crates/houndd/src/peer.rs):
- group `hound`: status, settings.get, events, quarantine.list,
rootkit.scan, persistence.scan
- scan/supply.sweep: only paths the caller could read itself, decided
by forking a child, dropping to the peer's uid, gid and
supplementary groups, and asking access(2) — which honours ACLs and
mount options, unlike anything reconstructed from mode bits
- everything that writes: root, or the uid the daemon runs as
Unclassified methods fall into Admin, so a new mutating method fails
closed rather than becoming public by omission. The end-to-end socket
test caught that "root only" broke every developer run; the owner
clause collapses to "root" under the packaged root daemon and is
verified to do so.
CAP_SETUID/CAP_SETGID join the gate capability set for the readability
check. There was a test asserting CAP_SETUID must never be retained —
it is updated with the reasoning rather than deleted. The daemon
already holds CAP_DAC_OVERRIDE and CAP_DAC_READ_SEARCH, so becoming
another user widens nothing that matters. The unit gains Group=hound so
the socket can be chgrp'd without CAP_CHOWN; it stays uid 0.
Also: the footer claimed "engine: ClamAV via Unix socket". It reports
what the daemon actually loaded, which has been yara-x since the engine
was replaced. Every error path in the front-end goes through explain(),
so a privilege refusal reads as "run it from a terminal: sudo hound …"
rather than "daemon error -32000".
358 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Armed the execution gate on the live server for the first time. It
reported itself armed on a dedicated tmpfs, and then let EICAR execute.
Counters: 0 allowed, 0 blocked. Not one event was ever delivered.
Cause: systemd gives the service a PRIVATE MOUNT NAMESPACE. Several
perfectly ordinary hardening options force one — ProtectProc,
ProtectKernelTunables, ProtectControlGroups — and none of them mention
it. FAN_MARK_MOUNT marks a vfsmount, and a private namespace holds its
own vfsmount for the same filesystem. So the daemon marked its copy,
every other process on the machine used the host's copy, and the gate
protected nothing while claiming to be armed.
That is the worst way for a security feature to fail: silently, with a
reassuring status line. Nothing in the unit tests could have caught it —
they run in the host namespace, where the mount mark works.
Fixed by always using FAN_MARK_FILESYSTEM, which marks the SUPERBLOCK.
A superblock is shared across namespaces, so events arrive from
everywhere, and scoping still works because a superblock is exactly one
filesystem: marking a dedicated mount covers that mount and nothing
else. mark_mount is kept for the smoke-test example, which runs outside
systemd, with a doc comment about when it lies to you.
Two more that only appeared once the gate was actually armed:
* SystemCallFilter=@system-service kills the daemon with SIGSYS the
moment the gate is switched on. fanotify_init and fanotify_mark live
in @privileged, which @system-service deliberately excludes. Granted
individually rather than by adding @privileged, which would also admit
setuid, chroot, bpf and kexec_load. Invisible until armed — the
service starts fine with the gate off.
* The capability reduction reported "60 capabilities could not be
dropped" while the end state was perfectly correct. systemd's
CapabilityBoundingSet had already done the work, and the service does
not hold CAP_SETPCAP afterwards, so every redundant drop failed EPERM.
It now checks what is actually present, attempts only that, and judges
by the end state rather than by return codes.
Also removed AmbientCapabilities from the unit. Ambient capabilities are
inherited by children, the daemon shells out to freshclam/rpm/pacman on
some paths, and a root process already receives the bounding set as
permitted — so it bought nothing except a way for CAP_SYS_ADMIN to leak
into a subprocess.
Performance, measured on the live server rather than guessed at:
+2.70 ms/exec as first written
+1.47 ms/exec after the reader blocked on poll() instead of sleeping
a millisecond between empty reads — that sleep sat on
the critical path of every execve
+1.38 ms/exec after answering cache hits in the reader thread, with
no channel handoff or worker wakeup
2,680 execs/sec sustained through the gate, 16-way parallel, with
ZERO watchdog rescues — the queue never fell behind. Ungated is 7,455.
Caddy stayed at sub-millisecond throughout and load did not rise.
Joe and Henry are right that the exec-heavy paths on this box — Docker
overlays, agent workspaces, PM2 — are the performance bar rather than an
exclusion list. Protecting agent workspaces from injected payloads is
the product. 2,680/sec with no backlog is roughly ten times what this
machine generates, so the bar looks clearable; stage 2 will say for sure.
295 tests pass, and the three-phase gate smoke test still passes
including the fail-open case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installed the .deb on the live server. The daemon did not start, and
everything below is what that one command surfaced.
1. MemoryDenyWriteExecute=yes stopped the service dead.
yara-x compiles rules to WebAssembly and JITs them, so it needs pages
that go writable then executable. With W^X enforced the daemon aborts
at startup: "unable to make memory executable". The unit had passed
systemd-analyze verify, which checks syntax and cannot know this.
Now off, with the reasoning in the unit rather than in a commit
nobody will read: a hardening directive that stops the service is
worse than the exposure it prevents, because the machine ends up with
no antivirus at all. What compensates is spelled out beside it.
2. Hound detected itself.
The goodware gate reported /usr/bin/houndd as Linux.Coinminer.XMRig
and Linux.Rootkit.Preload. Correctly: the built-in pack matches on
"stratum+tcp://", "xmrig", "RTLD_NEXT" and "ld.so.preload", and the
pack was embedded verbatim, so the daemon's own binary contained all
of them.
Not cosmetic. With the execution gate armed, Hound would have refused
to execute itself or quarantined its own binary — a scanner that eats
its own daemon the moment protection is switched on.
The pack is now XOR-masked at build time (build.rs) and unmasked at
startup. Not a secret — the rules are open source — the only job is
keeping the literal bytes out of the executable. Two regression tests:
the embedded blob carries no plaintext rule strings, and a built
daemon binary in target/ carries none either.
3. The postinst copied the built-in pack into /var/lib/hound/rules,
where the daemon compiled it a second time and logged a duplicate
declaration on every start. That directory is for ADDITIONAL packs;
the built-ins live in the binary. Removed from deb, rpm and AUR.
4. The daemon and the CLI disagreed about the socket. systemd gives the
service /run/hound; the CLI looked in $XDG_RUNTIME_DIR and reported
the daemon unreachable — technically true, entirely unhelpful.
default_socket_path() now prefers /run/hound when it exists, the unit
states HOUNDD_SOCK explicitly, and a permission error on the socket
says "try: sudo hound" instead of "Permission denied".
Also: Recommends: clamav-daemon was wrong and apt duly installed clamd,
which took 970 MB of RSS on the live server. clamd is an optional
arm's-length engine, so it is a Suggests. I stopped and disabled the
copy my install pulled in.
Verified on the server after fixing: service active, status reports the
engine and the gate, EICAR caught, rootkit scan clean, and the goodware
gate passes across 3,955 system binaries including the now-installed
houndd.
The gate remains OFF. Turning it on for the host that serves Caddy is a
separate decision.
295 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2. "Distro-agnostic" was a claim with no packaging behind it.
.deb and AppImage are both built and tested here; the rpm spec and
PKGBUILD are written but not yet built, since neither rpmbuild nor
makepkg exists on this machine.
The AppImage is deliberately not a packaged daemon. An AppImage is
unprivileged by design — no install, no root, no systemd — so the
execution gate cannot exist in one, and pretending otherwise would be
worse than saying so. What it is instead is the try-before-you-install
build: on-demand scanning, quarantine under the user's own data dir,
rootkit and supply-chain checks, all with nothing left behind. Asking
it for the gate prints why it cannot and how to get it. Verified with
an isolated HOME: status, scan, EICAR caught, gate refusal.
Two packaging bugs caught by looking rather than assuming:
* mktemp -d creates 0700 and dpkg applies the staging root's mode to
"/". Installing that package would have chmodded the root directory
to 0700 and broken the machine.
* AppRun wrote its log before creating the directory, and built a
socket path that can exceed sun_path (108 bytes) when
XDG_RUNTIME_DIR is long. Both fixed; the socket falls back rather
than failing with an error nobody can act on.
The systemd unit is hardened as far as this daemon can be. Notably
ProtectSystem=strict is WRONG here and is left off on purpose: it
remounts everything read-only, and quarantine has to remove a threat
from wherever it landed. ProtectHome is off for the same reason. The
CapabilityBoundingSet mirrors what caps.rs drops to, so the machine is
protected even if the binary is replaced by one that does not reduce
itself.
App icon: the mark in white on a periwinkle tile, per Joe. Small sizes
are not the same artwork scaled down — the mark is line-weight, so at
16px a 62% inset leaves about a pixel and a half of stroke and the head
turns to mush. Each size is authored with its own inset and corner
radius, and the ground goes flat below 32px because a gradient across
16 pixels is just noise. The tray ladder is untouched: those glyphs stay
transparent and re-tint per state so they can sit on any panel colour.
Package installs are NOT enabled by default beyond the daemon itself —
exec_gate stays off until the operator turns it on, in every packaging
format.
99 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>