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>
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>