Commit graph

15 commits

Author SHA1 Message Date
Hound
9f0c4e08d7 gate: the mark was invisible to every process but our own
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>
2026-08-21 08:04:19 -05:00
Hound
432e2b825e packaging: four bugs that only a real install could find
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>
2026-08-21 07:43:03 -05:00
Hound
42cd97d59f supply: read lockfiles, and only ever call a malicious package malicious
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>
2026-08-21 07:29:47 -05:00
Hound
4dab08a75a tests: kill a flaky test that reintroduced the race it was testing for
The previous commit went out with a failing test. It passed on a rerun,
which is worse than failing — a flaky test in a security product either
gets ignored or gets deleted, and both are how a real regression ships.

The offender was rootkit's own thread test. It read /proc/self/task and
took the /proc snapshot at different moments, so a thread started by
another test between the two reads looked like a thread that answered
kill() but was missing from the listing. That is precisely the
start/exit race the hidden-process check exists to avoid, reintroduced
in the test written to prove the check avoids it.

Each thread now reports its own tid via gettid and then parks, so all
eight are demonstrably alive across the whole measurement window. Five
consecutive full runs, 124/124 each.

Also isolated persistence's read-only-scan test behind the env lock:
baseline_path() reads XDG_DATA_HOME and the quarantine tests reassign
it, so two scans either side of that disagreed about first_run. It now
takes the lock, points at its own directory, and additionally asserts
the thing the test was named for — that a read-only scan writes no
baseline file, and that --accept does.

256 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:14:09 -05:00
Hound
3be16eeef9 hound-defs: OSV ingest, the IOC index, and signed packs
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>
2026-08-21 07:06:00 -05:00
Hound
778236822b houndd: the persistence ledger
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>
2026-08-21 06:50:35 -05:00
Hound
909afacb19 houndd: rewrite the rootkit checks so they stop crying wolf
Phase 5's correctness half. The previous implementation could not ship:
its two main checks were structurally wrong rather than badly tuned.

  hidden processes  was "any /proc/<pid> whose comm we cannot read",
                    which fires on every process that exits between the
                    listing and the read. A race, not a signal.
  setuid anomalies  compared against a hardcoded allowlist of binary
                    names, written on one distribution.

Replaced with questions that have factual answers:

  A process is hidden when the kernel agrees it exists and /proc does
  not list it. kill(pid, 0) answers the first half for the whole PID
  space — ESRCH means gone, EPERM means it exists and belongs to
  somebody else, which is the case that matters since a rootkit's
  process will not be ours. The sweep is bracketed by two listings and
  candidates are re-verified, so a process that merely started or
  exited during the scan cannot be mistaken for a hidden one.

  A setuid binary is suspicious when no installed package claims it.
  The package manager already knows what belongs on the system.

Two bugs found by testing against this machine rather than reasoning
about it, both of which would have made the feature useless in the
field:

* /proc lists thread-group leaders; kill() accepts any THREAD id. A
  process with twenty threads therefore has nineteen ids that answer
  kill and appear in no /proc listing. Comparing against the pid set
  alone reported dozens of criticals on a completely healthy laptop.
  The honest set is the union of leaders and their /proc/<tgid>/task
  entries.

* Merged-/usr breaks package ownership in BOTH directions. /bin is a
  symlink to usr/bin, so every binary has two names, and dpkg's own
  index is inconsistent about which it records: sudo.list says
  /usr/bin/sudo while fuse3.list says /bin/fusermount3 and cifs-utils
  says /sbin/mount.cifs. String comparison reported the entire setuid
  set as unowned. Both spellings now go into the index, candidates are
  deduplicated by resolved path, and lookups try both.

Also: ld.so.preload is now checked (it is empty on a healthy system and
is the classic userland rootkit), the writable-directory check no
longer counts sticky-bit directories, and the hidden-file check uses
symlink_metadata so an ordinary dangling symlink is not an incident.

Verified on this machine, privileged and not: clean, 0 findings, 2.8s
including the full 4.2-million-pid sweep. The exit criterion asks for
five machines across three distributions and only one was available
here, so treat the cross-distro half as unmet.

The regression tests are the point: a scan run while processes churn
continuously must produce no criticals, and a process with eight live
threads must not produce eight findings.

200 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 06:34:26 -05:00
Hound
6beb73771a hound-supply: the supply-chain and agent-era scanner
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>
2026-08-20 23:48:48 -05:00
Hound
85234842f9 houndd: gate covers writes too; inotify becomes the fallback
Completes Phase 1. The gate now asks for FAN_CLOSE_WRITE alongside the
permission events, so a threat written to disk is quarantined and a
threat being executed is refused — one mechanism, one mark, no
watch-descriptor ceiling and no blind spots outside a configured list.

Verified live. The nicest evidence is an error message:

  $ chmod +x /tmp/hound-live/malware.sh
  chmod: cannot access '/tmp/hound-live/malware.sh': No such file or directory

Hound had already quarantined it. `hound quarantine list` shows the
entry, the clean binary beside it still runs, and CapPrm/CapEff/CapBnd
read 000000000020000e.

Three bugs, each of which looked like working code:

* A file descriptor number is not an identity. The kernel allocates an
  fd per event and recycles the number the moment we close it, so one
  write arrives as FAN_OPEN_PERM on fd 6 and then FAN_CLOSE_WRITE on fd
  6 again. Idempotency keyed on the fd treated the second as a duplicate
  of the first and dropped it — detection ran, matched EICAR, and threw
  the result away. Events now carry a monotonic seq that is never reused.

* rename(2) fails EXDEV across filesystems, and for quarantine that is
  the common case rather than the exotic one: the vault is under
  /var/lib while threats land on /home, in a tmpfs, on a USB stick or
  in a container overlay. Quarantine now falls back to copy-then-unlink,
  unlinking only once the copy is safely down, and seals the stored file
  at 0600 with every execute bit cleared.

* The capability set was too small to do the job. CAP_DAC_READ_SEARCH
  lets us read a threat but not unlink it, so quarantine failed EACCES
  as root. The set is now four capabilities — SYS_ADMIN, DAC_READ_SEARCH,
  DAC_OVERRIDE, FOWNER. DAC_OVERRIDE is close to "write anywhere" and
  that is worth being honest about; an antivirus that quarantines cannot
  avoid it, because the threat is by definition in a directory somebody
  else owns. What the reduction still buys is what it excludes, and
  there is a test asserting SYS_MODULE, SYS_BOOT, SYS_PTRACE, NET_ADMIN,
  NET_RAW, AUDIT_CONTROL and SETUID never creep back in. Narrowing
  further means a separate privileged helper for quarantine.

realtime.rs is now documented as the unprivileged fallback and does not
start when the gate is armed — running both would scan everything twice
and quarantine the same file from two threads.

99 tests pass. HOUNDD_GATE_DEBUG=1 dumps every event and decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:27:34 -05:00
Hound
f003c58982 houndd: arm the gate from the daemon and shed root while doing it
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>
2026-08-20 22:51:41 -05:00
Hound
aa22ddbc38 houndd: execution gate over fanotify, with a fail-open watchdog
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>
2026-08-20 22:29:01 -05:00
Hound
6746182f18 houndd: replace the clamscan fork with yara-x in process
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>
2026-08-20 22:05:09 -05:00
6ef296caa1 Full feature set: realtime monitor, quarantine vault, rootkit scan, settings, events
Daemon (houndd)
- realtime.rs: inotify monitor over watched dirs (default ~/Downloads,
  ~/Documents, ~/Desktop), ClamAV scan on touch, on_detect action
  (quarantine/rename/remove), ransomware heuristic (writes/renames per
  minute above threshold -> 'watching'/'alarm' + critical event)
- quarantine.rs: SHA-256-keyed vault under ~/.local/share/hound/quarantine,
  add/list/restore/remove with original-path metadata
- rootkit.rs: setuid anomaly detection (allowlisted stock binaries),
  deleted-but-executing inodes, world-writable /usr /bin; 3 severity levels
- settings.rs: persisted ~/.config/hound/settings.json, hot-reload on set
- events.rs: ring buffer of severity-tagged events, query + clear

API (hound-api): Settings, Event, QuarantineEntry, RootkitScan/
RootkitFinding, RealtimeStatus types + 10 client methods; Status gains
engine field (engine-agnostic seam)

CLI (hound): events, quarantine list|add|restore|remove, settings
[show|paused|auto-update|notify|realtime on|off|watch|on-detect|
max-size|exclude], rootkit, realtime [status|on|off] — color human
output, --json everywhere

GUI (Tauri 2):
- 16 backend commands bridging every client method
- tray watcher: 1s poll loop, 4-state icon ladder (green/amber/red/gray),
  desktop notification on fresh critical events
- 6-tab frontend: Protection (hero + scan + update), Quarantine (vault
  manager + manual add), Realtime (stats + watch list + toggle), Rootkit
  (on-demand scan), Alerts (event log + clear), Settings (full editor)
- capabilities/default.json for dialog/notification/event permissions

Verified: 27/27 workspace tests, live E2E — EICAR dropped in ~/Downloads
auto-quarantined by the running daemon (critical event logged, file
removed from origin).
2026-08-20 20:33:44 -05:00
566e0cbccb Freshclam wrapper: update RPC, DB age in status, engine-agnostic fields
- 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).
2026-08-20 17:54:50 -05:00
2cf41ce3bd Rust engine + CLI over ClamAV Unix socket
- hound-api: shared wire types (Status/ScanRequest/ScanResult/Found)
  + line-delimited JSON-RPC client used by both clients
- houndd: daemon binding a Unix socket, dispatching status/scan to
  ClamAV, parsing per-file results, deduping --allmatch hits
- hound: clap CLI (status / scan --json), colored human output,
  exit codes 0=clean 1=threats
- workspace scaffolding: toolchain, gitignore, env example, editorconfig
2026-08-20 16:59:14 -05:00