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>
Four tools over MCP stdio, so a coding assistant can ask Hound about a
project at the moment it matters rather than after:
hound_check_project the full supply-chain sweep
hound_check_package is this package known bad, before installing it
hound_check_file one file: a model, a lockfile, a manifest
hound_check_mcp_config audit the servers your assistant already trusts
READ-ONLY, permanently. There is no hound_quarantine, no hound_delete,
no way to change a setting. An agent can be persuaded by the very
repository it is inspecting — that is the threat this product exists to
detect — so a destructive MCP tool would hand the attacker exactly the
capability they were reaching for. A test asserts every tool name starts
with hound_check_ and contains none of quarantine/delete/remove/restore/
settings/set/update/install/write/exec, so the property cannot erode.
The output is written to be read twice: by the model that called the
tool, and by the human reading that model's summary. That rules out rule
identifiers and jargon — a test greps the output for both — and it rules
out ambiguity about severity. A model reading "1 warning" may well decide
to proceed, so a critical finding leads with an explicit recommendation
and names the consequence: this runs code during installation, before
any of the project's own code runs.
Clean results say what they did NOT check. "Nothing wrong" that reads as
a blanket endorsement is worse than no answer, so a clean project notes
it is not a source review, a clean name check notes it did not read the
package's contents, and a clean MCP audit still points out that every
server listed runs with your permissions and is called without asking.
The server passes its own audit, which was the point:
{ "mcpServers": { "hound": { "command": "/usr/bin/hound-mcp" } } }
No npx, so nothing is fetched at launch. No env, so no secret is handed
over. No path argument, so it is granted no directory. Hound's MCP audit
flags all three in other people's configs; a security tool that failed
its own check would have answered the only question that mattered.
Verified against the installed binary.
Protocol notes, since both are easy to get wrong and fatal:
- a notification carries no id and must never be answered; MCP sends
notifications/initialized straight after the handshake, so replying
corrupts the stream on the first exchange
- an id of 0 is still an id, and treating it as absent silently drops
the first call from any client that counts from zero
- a tool failure is a RESULT with isError, not a JSON-RPC error: the
agent should see "I could not read that path" as an answer it can act
on, not a transport fault that looks like a broken server
- nothing but protocol messages ever goes to stdout; diagnostics go to
stderr, because one stray println corrupts the session
Shipped in the .deb, and the postinstall now prints the config entry.
334 tests pass.
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>