Commit graph

11 commits

Author SHA1 Message Date
Hound
6016e1b4ea packaging: .deb, AppImage, rpm spec, PKGBUILD, hardened unit, app icon
Phase 2. "Distro-agnostic" was a claim with no packaging behind it.

.deb and AppImage are both built and tested here; the rpm spec and
PKGBUILD are written but not yet built, since neither rpmbuild nor
makepkg exists on this machine.

The AppImage is deliberately not a packaged daemon. An AppImage is
unprivileged by design — no install, no root, no systemd — so the
execution gate cannot exist in one, and pretending otherwise would be
worse than saying so. What it is instead is the try-before-you-install
build: on-demand scanning, quarantine under the user's own data dir,
rootkit and supply-chain checks, all with nothing left behind. Asking
it for the gate prints why it cannot and how to get it. Verified with
an isolated HOME: status, scan, EICAR caught, gate refusal.

Two packaging bugs caught by looking rather than assuming:

* mktemp -d creates 0700 and dpkg applies the staging root's mode to
  "/". Installing that package would have chmodded the root directory
  to 0700 and broken the machine.
* AppRun wrote its log before creating the directory, and built a
  socket path that can exceed sun_path (108 bytes) when
  XDG_RUNTIME_DIR is long. Both fixed; the socket falls back rather
  than failing with an error nobody can act on.

The systemd unit is hardened as far as this daemon can be. Notably
ProtectSystem=strict is WRONG here and is left off on purpose: it
remounts everything read-only, and quarantine has to remove a threat
from wherever it landed. ProtectHome is off for the same reason. The
CapabilityBoundingSet mirrors what caps.rs drops to, so the machine is
protected even if the binary is replaced by one that does not reduce
itself.

App icon: the mark in white on a periwinkle tile, per Joe. Small sizes
are not the same artwork scaled down — the mark is line-weight, so at
16px a 62% inset leaves about a pixel and a half of stroke and the head
turns to mush. Each size is authored with its own inset and corner
radius, and the ground goes flat below 32px because a gradient across
16 pixels is just noise. The tray ladder is untouched: those glyphs stay
transparent and re-tint per state so they can sit on any panel colour.

Package installs are NOT enabled by default beyond the daemon itself —
exec_gate stays off until the operator turns it on, in every packaging
format.

99 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:37:17 -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
a7871b5e27 Engine seam (ScanEngine trait) + Tauri GUI
ClamAV is a temporary dependency. Everything ClamAV-specific moves
behind a 4-method trait in crates/houndd/src/engine.rs:

    trait ScanEngine { name; probe; scan; update }
    struct ClamAvEngine   // today
    const ENGINE          // one-line flip when the native engine lands

- parse_clamscan() is now a pure fn with unit tests (OK/FOUND/INCOMPLETE,
  dedup, malformed lines)
- main.rs drops all clamscan/clamd/freshclam knowledge; Status reports
  engine='clamav' via the trait
- README documents the seam and the wire contract that stays stable

GUI (gui/, Tauri 2, standalone workspace, vanilla JS premium-dark shell):
- system-tray sentinel on the 4-state dog-head ladder (green/amber/red/gray)
  with tooltips; clicks open the window / scan / update
- window: protection hero, path + recursive scan, progress bar, results
  table, signature update log; state mirrored to the tray
- compiles clean (cargo build, 0 warnings) on Mint 22.3 + webkit2gtk-4.1

All 11 workspace tests pass, incl. the E2E EICAR scan over the real
Unix-socket daemon (clamscan finds the planted EICAR).
2026-08-20 17:55:40 -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
bbbabefdda Brand identity: dog-head mark + 4-state tray ladder
- assets/icons/hound.svg: solid dog-head brand mark (single flat fill)
- Brand ladder hound-{16..256}.png in native periwinkle #9896E0
- Tray-state ladder re-tinted per security state (16..48px):
    protected #22C55E (good) / scanning #F59E0B (in progress)
    threat #EF4444 / paused #6B7280
- README: icon system table, architecture, quickstart, verified EICAR
  sample, git conventions (no hardcoded bot token)
2026-08-20 16:59:22 -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
9dfe685db4 Initial commit 2026-08-20 21:23:37 +00:00