Malware scanning asks whether a file is hostile. The check that
actually loses people their accounts is a different one: what has
already been exposed, and what is about to be? crates/hound-supply/src/
hygiene.rs answers it, and runs as part of every project sweep.
- A secret file tracked by git. The emergency case: it is in the
history, in every clone, and in every fork. The advice says rotate
BEFORE `git rm --cached`, because removing a pushed secret does not
un-share it, and a test asserts that ordering.
- Credentials hardcoded in source, recognised by issuer format —
AWS, GitHub, Anthropic, OpenAI, Stripe, Slack, GitLab, npm, PyPI,
Google, and the PEM private-key headers.
- Secret files readable by every account on the machine.
- Secret files with nothing in .gitignore covering them: the near
miss that the next `git add -A` turns into the emergency above.
- GitHub Actions: pull_request_target with a checkout of the pull
request (a stranger's code, your secrets, your write token), a
secret echoed into the build log, a downloaded script piped into a
shell, and third-party actions on a moving tag.
Two rules govern all of it. **Findings are actionable**: no entropy
heuristics, because "high entropy string" is a coin flip a human then
has to adjudicate, and people stop reading after the second false
alarm. Every detector recognises a documented credential format or
reports a structural fact that is true or false. **Nothing secret is
copied into a finding** — a report naming the key it found has moved
the key into a log, a CI artefact, or an assistant's context window,
which is the thing being prevented. There is a test for that.
Tracked-file status comes from parsing .git/index rather than running
git: the sweep is pointed at repositories precisely because they are
not trusted, and starting a subprocess inside one is what a hostile
repository wants.
Against a deliberately bad test repository: four critical, five
warnings, and correctly silent on .env.example and actions/checkout@v4
— flagging those is how a scanner gets ignored.
Also in this release:
- Right-click "Scan for Threats with Hound" in Nemo, Caja and
Dolphin, whose menu entries are system files. GNOME Files and
Thunar keep theirs per-user, so `hound context-menu install`
handles those. The icon is symbolic, so the file manager recolours
it to the menu's own theme instead of dropping a violet dog into a
monochrome menu.
- A right-click while the app is already open hands the request to
the running instance rather than refusing. A menu item that
silently does nothing because the app happens to be open is
indefensible.
- Two fixes for the duplicate tray icon. The updater slept a fixed
600ms after SIGTERM and then started the replacement; if the old
process outlived that, the panel kept its item and the result was
two dogs, the older of which could not be clicked or closed because
nothing was behind it. It now waits for the process to actually
leave /proc, escalating to SIGKILL after five seconds. And the app
itself now holds an advisory lock for its lifetime, so a second
instance cannot exist — the kernel releases the lock however the
process dies, so a stale one is not a state that can happen.
402 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every serious bug found in desktop testing had one shape: Hound
reporting success it had not achieved. A build script that said "built"
without building. A window showing "Protected" while its front-end had
failed to load. A rootkit check calling 988 processes hidden when it
was the one that had been blinded. A settings write refused while the
switch stayed where the user put it. A visible error is something a
person can act on; a false green is not.
`hound selfcheck` asks the question directly — what can this
installation not currently do? — and the daemon prints anything wrong
at startup rather than waiting for it to be inferred from behaviour.
The states are ok, degraded, and blind; the last is the one that
matters, because blind means a detector is running and cannot see. It
exits non-zero when blind, so it can be wired into monitoring.
It earned its place within a minute of existing, by reporting the vault
as /root/.local/share/hound/quarantine. The daemon runs as root, root
has a home directory, and the XDG rules therefore sent the system vault
into root's dotfiles — while the installer created and hardened
/var/lib/hound/vault, which sat empty, and the desktop app read the
user's own vault. Three vaults, none agreeing, which is exactly why the
Quarantine tab reported "vault is empty" beside two quarantined files.
Root now uses the system vault; an unprivileged daemon keeps its own,
since a developer running houndd by hand must not need /var/lib.
Also: `hound update` restarts the desktop app itself after installing.
The app can notice its own package being replaced, but only from the
version that learned how — updating from an older one leaves the stale
process showing the old front-end, which is indistinguishable from an
update that did nothing. The updater matches processes on the
executable rather than a command line anyone could imitate, and
relaunches each as its own owner with the session environment it was
already using: DISPLAY, Wayland socket and bus address are taken from
the running process, because guessing them breaks on Wayland or a
second seat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three reports, one shape: the app told the user something had happened
when it had not.
**Appearance was stored in the wrong place.** theme and tray_icon_style
lived in the daemon's settings, which need root to write. So toggling
to a monochrome tray produced a rejected write, no visible change, and
a switch still sitting where it had been put — the screen disagreeing
with reality. Appearance is a per-user preference: it belongs to the
person, not the machine, and putting it in the daemon would also have
made one user's choice everyone's on a shared box. It is client-side
now, applies the instant the control moves, and never prompts for a
password.
**There was a Save button.** A settings screen with one lets you walk
away with your changes discarded, and here it also hid the failure
above. It is gone. Every control writes on change, batched behind a
short delay so toggling three switches is one request and one
authentication prompt rather than three, and typed fields wait for a
pause rather than firing per keystroke. A write the daemon refuses now
reloads the real settings, so a control never keeps a value that was
not accepted.
**The app did not notice being replaced.** After an update the running
process is still the old binary showing the old front-end, which is
indistinguishable from an update that did nothing — the Appearance
panel was in the installed package the whole time and could not be
found without quitting and relaunching. The daemon restarts on upgrade,
so its reported version is the authority on what is installed; when it
stops matching this process's own, the app says so and reopens. One
attempt only, so a version that never matches cannot become a restart
loop.
Also: the tray repaints immediately on an appearance change rather than
waiting for the next poll, and the stored appearance is applied before
first paint so the window does not flash the wrong theme on the way in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A home scan reported two EICAR threats whose paths were Hound's own
vault. SKIP_PREFIXES held the system vault at /var/lib/hound/vault but
not the per-user one under $XDG_DATA_HOME/hound/quarantine, which is a
different absolute path for every user and so cannot be a literal. The
check now matches a `hound` component followed by `quarantine` or
`vault` anywhere in the path, with a test that ordinary paths merely
mentioning either word are still scanned. A scanner that detects its
own evidence locker reports threats that no longer exist anywhere they
can hurt anyone.
Appearance settings, the third pair of fields declared from the start
and wired to nothing (after close_to_tray and auto_update_signatures):
- Theme: follow system | light | dark, previewing live rather than
only on save, since a control that does nothing until you press
another control feels broken.
- Monochromatic tray icon: one glyph instead of the colour ladder.
The tooltip still names the state, so only the colour is dropped.
The light theme had to be built — the stylesheet was dark-only. It is
not an inversion: the state colours are darkened until they hold their
contrast on white (the dark theme's green is 2.2:1 there, unreadable as
text) and the neutrals keep a slight violet bias so they read as chosen
rather than as a default grey. One hardcoded near-black on the log
panel would have been near-invisible in light mode; it is a token now.
Which monochrome tone to draw depends on the panel, and no portable way
exists to ask a panel what colour it is — so the webview resolves the
theme (including "follow system", which only prefers-color-scheme can
answer) and tells the tray. One place decides which theme is showing.
The tray now announces a release once per version rather than once per
poll, and its menu entry reads "Update available — install Hound X…".
Also removes ~120 lines: the GUI had its own download, hash-check and
staging implementation for updates. `hound update` does exactly that,
as root, and is the path with tests behind it, so the app runs it under
pkexec instead. That left `hound stage-update` with no caller, and dead
privileged code paths are liabilities, so it is gone.
Verified end to end: 0.1.2 discovered 0.1.3 and installed it
unattended. 380 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Definitions and the application were two separate acts with two
different mechanisms, and only one of them had a command. `hound
update` now does both:
sudo hound update definitions, then offer the app
sudo hound update -y both, unattended
hound update --check what is available, install nothing
sudo hound update --definitions-only
Definitions run first and independently: a release check that fails —
laptop offline, host down — is a warning, not a failed command. The
definitions half is what matters between releases and it either worked
or it did not, regardless of what the manifest server did.
The app half prompts unless given --yes, because definitions are
verified before they are parsed while replacing the running binary is a
larger step. Non-interactive callers never block on a read: they are
told to pass --yes and exit cleanly.
`release.check` is classified read-only, so any member of the `hound`
group can ask whether they are current — it changes nothing and reveals
nothing the website does not. Installing still requires root and says
so.
Verified end to end on a real machine: a 0.1.1 install discovered
0.1.2, verified the signed manifest, checked the SHA-256, staged it
root-owned and installed it through apt, unattended.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two mechanisms Joe asked for, neither of which existed.
**Definitions now update themselves.** `auto_update_signatures` shipped
from the start and nothing ever read it — definitions only moved when
somebody typed `hound update`, which for a security product means most
installations were running whatever they were installed with. There is
now a scheduler thread: definitions hourly, release check daily, and a
90-second startup delay so a fleet that reboots together does not
arrive at the CDN in one wave. It calls the same install path as
`hound update` rather than a second implementation that could drift.
**Release notifications, signed.** dl.houndav.com/latest.json carries
an Ed25519 signature from the same key as the definition packs, for the
same reason: whoever serves that host must not be able to invent a
version and point our users at a binary of their choosing. A manifest
that does not verify is discarded, and no manifest means "nothing newer
known" — never "update available". The safe default is silence.
The signature covers a canonical byte form defined identically in
release.rs and tools/publish-release.py. Signing a re-serialisation of
a parsed struct is the classic way to verify one thing and act on
another, so a test runs a real manifest produced by the Python tool
through the Rust verifier against the production key. If those drift,
manifests verify nowhere and the only symptom is that nobody ever hears
about an update.
Version comparison is numeric, not lexical, because "0.9.0" > "0.10.0"
as strings and that is precisely the pair where it would first be
noticed. Downgrades are never offered — a signed-but-old manifest must
not become a way to reintroduce a fixed vulnerability.
The app does not replace its own binary. A root daemon that can rewrite
itself is the mechanism a supply-chain attacker most wants. Installing
goes through apt with the user present and authenticating: the package
is downloaded, checked against the manifest's SHA-256, staged into a
root-owned 0700 directory so nothing can substitute it between the
check and the read, and handed over. Two guards beyond the signature —
the URL must be on our own download host, because a signature proves
publisher intent and not that the publisher got the URL right.
Tray, per Joe's design: a new amber "attention" state ranked below a
threat and an in-flight scan, above protected. Deeper amber than
"scanning" so the two are distinguishable — scanning lasts seconds and
the user started it, attention persists. The tooltip carries the reason
rather than only that there is one, and "Install Hound X…" appears
enabled only when there is something to install; a permanently greyed
item teaches people the menu is decorative.
Definition staleness is a first-class signal, not just update
availability: amber at 7 days, and at 30 the wording stops pretending —
"this machine is not currently protected against anything found since
then". An antivirus showing green on month-old definitions is lying in
the same way a dead front-end showing "Protected" was.
Also: the four files that declare a version now have a test asserting
they agree. Drift there means a release looks older than what is
installed and the update silently never offers, or offers forever.
377 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes Phase 3's delivery half. `hound update` now asks the definitions
host what exists, downloads what this machine lacks, verifies it, and
installs it. Verified end to end against the live host over TLS:
starting from an empty directory:
defs: no verified packs were found
hound update:
definitions: 1 pack(s) installed, 0 already current
crates-io-2026.08.21.pack — 19 indicators, version 2026.08.21
loaded 19 indicators from 1 pack(s) [2026.08.21]
and immediately afterwards a lockfile naming rustdecimal is flagged,
while tokio beside it is not.
running it again:
definitions: definitions are up to date (1 pack(s))
a pack with one byte flipped in its signed payload, advertised in the
index with a correct hash and a version of 9999.1.1:
definitions: 0 pack(s) installed, 1 up to date, 1 REJECTED
tampered-test.pack: the definitions pack is not signed by Hound and
was discarded
Three refusals are the design:
* THE INDEX IS A HINT, NEVER AN AUTHORITY. It says which packs exist and
what they hash to, and both are unverified — anyone who can serve the
index can lie about either. Only the Ed25519 signature decides whether
a pack is real. A tampered index can waste bandwidth and nothing else,
which is exactly what the test above demonstrates: correct hash,
higher version, still refused.
* NOTHING UNVERIFIED REACHES THE DEFINITIONS DIRECTORY. Downloaded to a
temp file, verified there, then moved with a rename inside the same
directory so it is atomic. The daemon cannot observe a half-written
pack, and a crash mid-download leaves a stray temp file rather than a
loadable one.
* A FILENAME FROM A REMOTE INDEX IS UNTRUSTED INPUT. The updater runs as
root, so an entry of ../../../etc/cron.d/evil.pack would be remote code
execution. Only a plain basename ending in .pack, with no separators,
no dot-dot and no leading dot, is accepted. Tested against eight
hostile shapes.
Sizes and timeouts are bounded — a pack is a list of package names, so a
server offering a hundred gigabytes is broken or hostile and the
difference does not matter to a full disk. The read is bounded
independently of Content-Length, which is also just something the server
said.
Definitions are fetched BEFORE rules are reloaded, and a failure to fetch
does not stop the reload. No network, a mirror down, a pack that will not
verify — none of those are a reason to skip the half that still works.
HOUNDD_DEFS_URL points the client at a mirror, which matters for the
air-gapped deployments that are a real part of the Fleet story.
build-pack now writes index.json beside the pack, so publishing is one
command.
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>
Phase 3's foundation. Three jobs that share a data model:
osv parse the ossf/malicious-packages feed (Apache-2.0, ~226k
records, daily) into indicators
index answer "is this package known bad?" fast enough to ask it
thousands of times per project sweep
pack sign a definitions pack, and verify one before loading it
Validated against the real feed rather than fixtures: the whole
crates.io OSV export, 2,749 records, parsed with zero failures — 3,885
indicators across ten ecosystems, 19 of them MAL-. `rustdecimal` (the
real typosquat of rust_decimal) resolves in crates.io and stays clean
in npm and PyPI, which is the ecosystem isolation working.
Notes on the three:
* A malicious-package record is not a vulnerability record. It almost
always carries introduced:"0" with no fix, meaning EVERY version is
malicious — the package exists only to be malware, so there is no safe
version to upgrade to. Conflating that with a version-bounded
vulnerability either misses real hits or condemns safe versions of
legitimate packages, so the two are modelled separately.
* The index is a cuckoo filter in front of a map. Cuckoo rather than
bloom specifically because a definitions feed needs DELETION: OSV
withdraws records — it once withdrew 157 malware reports after a
false-positive incident — and a filter you cannot remove from means a
withdrawn record costs a probe forever or forces a rebuild. 226k
indicators fit in under 4 MB; the crates.io set is 8 KB.
The property that must never break is no false negatives, and it has
its own test. A false positive costs a hash lookup; a false negative
is malware reported as clean. That is also why a fingerprint hashing
to zero is nudged to one — zero marks an empty slot, so without the
nudge one key in 65,536 would silently vanish.
* Signing is not about entitlement; the subscription gates the server.
It answers "is this pack really from us?", because someone who can
substitute a definitions file can add an entry for /usr/bin/sudo and
have Hound quarantine it on every machine that updates — a supply
chain attack delivered through the security product. Verification
happens on the raw bytes BEFORE anything parses them, so a hostile
pack never reaches the parser at all.
256 tests pass across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes Phase 5. Half of a Linux compromise is not a file on disk, it
is a line added to a startup file — a curl in a shell profile, a systemd
unit with a dull name, one extra key in authorized_keys. The payload is
often unremarkable; what makes it an incident is that it survives a
reboot and nobody reads those files from one year to the next.
So this is not a scanner but an inventory with a memory. It records
systemd units (system and per-user), cron in all its locations,
autostart entries, shell profiles, authorized_keys and ld.so.preload,
then reports what CHANGED.
Three decisions, all of which are the difference between a report people
read and one they turn off:
* Content is hashed, not stat'd. An mtime can be set backwards with one
touch, and someone editing a startup file is exactly the person who
would. Verified: a backdated edit is still caught.
* A first run reports no changes and says so. Everything would be a
change, and a first-run report full of alarms is one nobody reads.
What a first run can honestly say is how many entries no package
claims — 97 of 1,026 on this machine — because that is true
regardless of history.
* Writing the baseline is an explicit act (`--accept`, or
update_baseline on the wire). A plain check must never quietly record
whatever is currently installed as normal; that is how a compromise
becomes the new baseline.
Package ownership decides what is ordinary: a unit that arrived with a
package is the system working, the same unit unowned is somebody's
decision. Reuses the merged-/usr-aware index from the rootkit rewrite,
with a test asserting most units resolve to a package — if that ratio
collapses, ownership lookup has broken and the whole report is noise.
Exercised end to end against this machine: baseline of 1,026 items,
a planted user unit caught as ADDED, an in-place edit with a backdated
mtime caught as CHANGED, and its deletion caught as REMOVED. The test
artifact was removed afterwards.
Cross-distro verification of the rootkit rewrite is now MET. Henry ran
the suite on the Ubuntu box (26.04, glibc 2.43): 20/20, including both
unowned_setuid_does_not_fire_on_a_healthy_system and
no_false_positives_on_system_binaries.
214 tests pass across the workspace.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4's detection core, as a standalone crate. This is the part with no
competitor on Linux, and deliberately the part with no Linux in it —
file parsing and logic only, no fanotify, no /proc, no eBPF — so the
macOS and Windows port is weeks rather than a second product.
Five detectors, 88 tests:
pickle GLOBAL/STACK_GLOBAL walk over .pt/.ckpt/.pkl/.joblib.
torch.load runs a stack machine; a model file is a
program and downloading weights is a code-execution
decision.
injection Instructions aimed at a coding agent in CLAUDE.md,
AGENTS.md, .cursorrules, copilot-instructions.
installscript preinstall/postinstall hooks that curl|sh, decode and
run, reach for credentials, or install persistence.
typosquat Damerau-Levenshtein against popular names, plus
slopsquat detection: new + near-zero downloads + one
edit from something popular is the signature of a name
a model invented and somebody then registered.
mcp Servers fetched unpinned at launch, handed secrets, or
pointed at $HOME or credential paths.
Wired through `supply.sweep` on the socket and `hound supply-chain
<path>`, which exits 1 on a critical so it drops into CI.
Three things worth recording:
* Scoring is by independent category, not by keyword count. One
suspicious phrase is a phrase; two categories at once is an attack.
A file that only says "ignore previous instructions about formatting"
is a warning, not a critical.
* Proximity matters more than presence. The first version flagged an
entirely ordinary conventions file, because it mentioned ".env" in
one paragraph and "prefer small commits" in another. A credential and
a movement verb now have to appear within a sentence of each other.
The test that caught it is kept as the regression.
* Pickle call detection has to come out of the opcode walk, not a byte
search. REDUCE, INST and OBJ are the ASCII letters R, i and o, which
also occur inside every string the stream carries — searching raw
bytes finds the o in "os" and reports a call that never happens,
turning every warning into a critical.
Every finding carries a plain-language explanation and a next step, and
there is a test asserting explanations do not leak rule identifiers or
jargon. The audience includes people who cannot triage a YARA match and
should never be shown one.
Verified against a demo project holding a squatted @vue plugin with a
curl|sh postinstall, a poisoned CLAUDE.md in a vendored repo, an
unpinned MCP server holding a GitHub token, and a pickle calling
os.system — four criticals and one warning, while the legitimate
CLAUDE.md, the real express manifest and the properly-scoped MCP server
beside them stayed clean.
189 tests pass across the workspace.
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>
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>
- hound-api: DbFile (file + updated_at), Status.db, UpdateResult, Client::update()
- houndd: probe() extracts signature freshness from /var/lib/clamav mtimes;
update() tries 'sudo freshclam', falls back to plain 'freshclam' and
reports the honest reason on failure; output capped to 2KB tail
- hound CLI: 'hound update' with --json parity and human output
- rename Status.clamav_present -> engine_present + add Status.engine
(prep for the engine seam; wire stays engine-agnostic)
Live-verified: status shows DB file + last-modified RFC3339; update
returns ok:false with the real log for a plain user (exit 1).