**The action.** `hound hygiene` and `hound supply-chain` only ever saw
repositories somebody had already cloned onto a machine with Hound
installed. action/ runs them on every push and pull request: it
installs the published .deb, verifies it against the same signed
checksum the desktop agent uses, and annotates findings on the lines of
the files they concern so a reviewer sees them in the diff rather than
in a log nobody opens.
report.py will not print a credential it found — GitHub masks only
values registered as secrets, so anything else in an annotation is
readable by everyone who can see the run and stays in the API
afterwards. And it will not report a partial scan as clean: a history
walk that hits its limit says so, because "no findings" and "no
findings in the part we looked at" mean different things to somebody
deciding whether to merge.
**The background.** A radial gradient was set on `html, body` — both,
each 100% tall — so it painted twice and the seam between the two
layers appeared as a band across the middle of the page when scrolled.
It was also a hardcoded near-black the light theme had no way to
override. Three more like it: the active tab, button hover, and the log
panel. The ground is a token now, and every colour in the stylesheet
comes from one, so no rule can put one theme's text on the other's
background.
**The palette.** The light theme now uses houndav.com's values exactly
— #5A58C8 buttons, #147A3D, #9A6100, #C22222 — so the app and the site
are recognisably the same product rather than two guesses at it.
Then measured rather than assumed, and found two failures Joe had not
mentioned: "faint" text was 2.90:1 in dark and 3.37:1 in light, and the
dark button hover was 4.41:1. All three now clear 4.5:1, and all eight
text pairs pass WCAG AA in both themes.
**And four more places still naming ClamAV**, which has not been the
engine for a long time: the auto-update caption said the daemon runs
freshclam, the update log said the same, an error suggested `apt
install clamav`, and a permissions hint pointed at /var/lib/clamav. The
engine swap replaced the code and left the copy describing software
this product no longer runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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 machinery has been real for a while and the content was 19 crates.io
records. Now it is the whole malicious-packages feed:
npm 220,324
PyPI 11,702
RubyGems 3,512
Go 19
crates.io 19
Packagist 1
────────────────────
235,577 indicators, 45 MB across six signed packs
The striking number is npm: 220,323 of its 227,149 OSV records are
malicious packages rather than vulnerability advisories. NINETY-SEVEN
PERCENT. That is the whole thesis in one statistic — the dominant
security fact about the npm ecosystem is not that libraries have bugs,
it is that the registry is full of things that exist only to be malware.
It is also why the malicious-versus-vulnerability classifier had to come
first: without it this pack would have been 227,149 indicators, and
7,000 of them would have been ordinary libraries.
Verified end to end from a genuinely empty definitions directory:
before: defs: no verified packs were found
update: 6 pack(s) installed, 0 already current
loaded 235577 indicators from 6 pack(s) [2026.08.21]
elapsed: 1.4 seconds for 45 MB, verified and loaded
RSS: 214 MB (unit cap is 1G)
And it detects. A lockfile with 500 ordinary packages and three real
malicious ones — ineldua, dian-kue20-riris, restart-rocket-jasmine-jwt,
picked at random from the feed rather than chosen to work — produced
three criticals and no false positives, in 11 milliseconds. The cuckoo
filter is doing exactly what it was built for: 500 clean lookups never
touch the map.
tools/ingest-osv.py does the coarse filter so build-pack does not walk a
quarter of a million files it will discard. It mirrors the Rust
classifier deliberately, and the Rust side still applies its own — a
record slipping through the Python changes nothing.
`hound status` now reports definitions, and reports them when there are
none, with the reason. Silence would let somebody believe they were
protected by a feed they never received.
Known and not yet solved: every agent downloads 43 MB of npm pack on
first update, and the whole pack again whenever it changes. Incremental
updates and a CDN in front of defs.houndav.com are both wanted. The
subdomain split exists precisely so the second one is a DNS change.
362 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lockfile parsing for npm (all three lockfile versions), yarn, cargo,
poetry, requirements.txt, go.sum, Gemfile.lock and composer.lock, wired
through the indicator index so a sweep checks real dependencies against
real definitions. Signed packs load in the daemon; the sweep gets the
index; `hound supply-chain` cites the OSV record it matched.
A lockfile is the right thing to read: it names every transitive
dependency at an exact version in one small file, and it lists what WILL
be installed rather than what already is — which matters when the
payload runs during installation.
Every parser is hand-written rather than pulling in a TOML and a YAML
crate. Two fields from each format, and a scanner parsing hostile input
should have as little parsing surface as it can.
The important part of this commit is a false positive it fixes.
Building a pack from the whole crates.io OSV export and sweeping a
project produced TWO criticals: rustdecimal, correctly, and **tokio
1.38.0**, which is not malware and never has been. The export is 1,524
GHSA and 1,206 RUSTSEC vulnerability advisories against 19 malicious-
package records, and the parser treated all of them as malware.
GHSA-2grh-hm3w-w7hv describes a tokio race condition fixed in 1.8.1;
Hound reported a version released years later as malicious.
Two independent bugs, either of which alone is fatal:
* Vulnerability advisories were ingested at all. A malicious package
should not exist; a vulnerable one is a legitimate library with a bug
and most of its versions are fine. Records must now PROVE they are
malicious-package reports — a MAL- id, the malicious-packages-origins
marker, or GHSA's "Malicious code in" wording — and anything
unrecognised is dropped.
* Unrecognised version ranges fell back to "all versions", which is the
opposite of safe. That is what turned a range of 1.8.0-to-1.8.1 into
a verdict on every tokio ever published.
Rebuilt against the same input, the pack now holds 19 indicators rather
than 3,614, rustdecimal is still caught and cites MAL-2022-1 rather than
a GHSA advisory, and tokio and serde are clean. The real tokio advisory
is now a regression fixture, because anything that flags tokio is a
product nobody trusts twice.
Also: definitions loading fails CLOSED on authenticity and OPEN on
everything else. No trusted key means no definitions and a message
saying so, because an operator who believes they are protected and is
not is worse off than one who knows. A pack that fails verification is
skipped and the rest still load. No packs at all is a working daemon —
install scripts, prompt injection, pickles and MCP audits need no feed.
There is deliberately no placeholder signing key compiled in. A fake key
that looks real is how a development shortcut becomes a shipped
vulnerability; an empty trust store is noisy in the way that gets fixed
before release. HOUNDD_DEFS_KEY supplies one for development.
294 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>
Wires the execution gate into DaemonState::boot, reports it on the wire
and in `hound status`, and reduces the daemon to the two capabilities it
actually needs.
Verified live, gate marked on a scratch tmpfs rather than /:
clean binary ran
EICAR binary execve -> EPERM, "Operation not permitted"
hound status Exec gate: armed on /tmp/hound-live, 2 allowed, 1 blocked
CapPrm/CapEff/CapBnd 0000000000200004 (CAP_SYS_ADMIN | CAP_DAC_READ_SEARCH)
Three ordering bugs found by checking rather than assuming, all of which
returned success while doing nothing:
* Capabilities are per-thread. Dropping them after spawning the reader
and workers reduced only the main thread and left four workers holding
full root — the exact opposite of the intent. The drop now happens
after fanotify_init and the marks, but before any thread exists, so
workers inherit the reduced set.
* PR_CAPBSET_DROP needs CAP_SETPCAP in the effective set, and capset had
already thrown it away. Every bounding-set drop failed EPERM, silently,
leaving a full CapBnd behind a log line claiming otherwise. Bounding
set is now drained first, while the authority to do it still exists.
* Because both of the above looked like successes, drop_to_gate_minimum
now reads CapEff and CapBnd back from /proc/self/status and errors if
they are not what was asked for. A privilege reduction that cannot be
observed has not happened.
Also: `hound status` grew an Exec gate line. timed_out above zero is the
number worth alarming on — it means the watchdog is releasing processes
unscanned and the gate has quietly degraded to advisory.
Gate arming and every failure path now log to stderr, so the journal
records a security-relevant state change instead of only the in-memory
event ring.
86 tests pass.
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).