rootkit: stop reporting every process on the machine as hidden

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>
This commit is contained in:
dev 2026-08-21 11:21:06 -05:00
parent 3b3586b60a
commit a3f31288fa
12 changed files with 489 additions and 35 deletions

View file

@ -484,6 +484,12 @@ impl Client {
} }
/// Connect and issue a single request. Returns the decoded `result`. /// Connect and issue a single request. Returns the decoded `result`.
/// Send one request by name. Used by the `admin-rpc` transport, which
/// forwards whatever the desktop app could not perform unprivileged.
pub fn raw_call(&self, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
self.call(1, method, params)
}
pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> { pub fn call(&self, id: u64, method: &str, params: Option<Value>) -> anyhow::Result<Value> {
let mut stream = UnixStream::connect(&self.sock) let mut stream = UnixStream::connect(&self.sock)
.map_err(|e| { .map_err(|e| {
@ -493,7 +499,14 @@ impl Client {
// quarantine files — so the answer is almost always sudo. // quarantine files — so the answer is almost always sudo.
let hint = match e.kind() { let hint = match e.kind() {
std::io::ErrorKind::PermissionDenied => { std::io::ErrorKind::PermissionDenied => {
"\n the system daemon's socket is root-only — try: sudo hound …" // The socket is group-readable by `hound`; being
// refused almost always means the account is not in
// that group yet, or was added and has not logged
// back in — group membership only applies to new
// login sessions.
"\n you are not in the `hound` group in this session. The installer adds\n\
\x20 you, but it only takes effect after you log out and back in.\n\
\x20 In the meantime: sudo hound "
} }
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => { std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
"\n is the daemon running? try: sudo systemctl status houndd" "\n is the daemon running? try: sudo systemctl status houndd"

View file

@ -33,9 +33,22 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Show engine status (daemon version, ClamAV, signature-DB age) /// Show engine status (daemon version, engine, signature-DB age)
Status, Status,
/// Scan a file or directory with ClamAV /// Forward one JSON-RPC request from stdin to the daemon (internal).
///
/// The desktop app cannot perform administrative actions itself — the
/// daemon requires uid 0 for anything that writes. Rather than teach the
/// app to elevate each operation separately, it runs this under `pkexec`,
/// which asks polkit to authenticate the user and then runs us as root.
/// One request, one response, no interactive state.
///
/// This grants exactly what `sudo hound` already grants, to exactly the
/// people polkit would let run `sudo` — it is a transport, not a new
/// privilege.
#[command(hide = true)]
AdminRpc,
/// Scan a file or directory
Scan { Scan {
/// Path to scan (file or directory) /// Path to scan (file or directory)
path: String, path: String,
@ -46,7 +59,7 @@ enum Cmd {
#[arg(long)] #[arg(long)]
json: bool, json: bool,
}, },
/// Refresh ClamAV signature databases (freshclam) /// Download the latest signed definition packs
Update { Update {
/// Emit machine-readable JSON instead of human text /// Emit machine-readable JSON instead of human text
#[arg(long)] #[arg(long)]
@ -152,6 +165,11 @@ enum SettingsCmd {
#[arg(value_parser = ["quarantine", "alert"])] #[arg(value_parser = ["quarantine", "alert"])]
mode: String, mode: String,
}, },
/// Refuse malicious binaries at execve, before they run (needs a restart)
ExecGate {
#[arg(value_parser = ["on", "off"])]
mode: String,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -383,6 +401,21 @@ fn client(sock: &Option<String>) -> Result<Client> {
/// Returns the process exit code. /// Returns the process exit code.
fn run(client: &Client, cmd: &Cmd) -> Result<i32> { fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
match cmd { match cmd {
Cmd::AdminRpc => {
use std::io::Read as _;
let mut line = String::new();
std::io::stdin().read_to_string(&mut line)?;
let req: serde_json::Value = serde_json::from_str(line.trim())
.map_err(|e| anyhow::anyhow!("decoding the request: {e}"))?;
let method = req
.get("method")
.and_then(|m| m.as_str())
.ok_or_else(|| anyhow::anyhow!("the request has no method"))?;
let params = req.get("params").cloned();
let value = client.raw_call(method, params)?;
println!("{}", serde_json::to_string(&value)?);
Ok(0)
}
Cmd::Status => { Cmd::Status => {
let st = client.status()?; let st = client.status()?;
if st.engine_present { if st.engine_present {
@ -560,6 +593,30 @@ fn run(client: &Client, cmd: &Cmd) -> Result<i32> {
s2.ransomware_threshold_per_min s2.ransomware_threshold_per_min
); );
} }
Some(SettingsCmd::ExecGate { mode }) => {
let mut next = s.clone();
next.exec_gate = mode == "on";
let s2 = client.set_settings(&next)?;
if s2.exec_gate {
println!(
"{} execution gate enabled — {} to arm it",
"".green().bold(),
"sudo systemctl restart houndd".yellow()
);
println!(
" The gate needs CAP_SYS_ADMIN and covers the whole root"
);
println!(
" filesystem. Watch it with: journalctl -u houndd -f"
);
} else {
println!(
"{} execution gate disabled — {} to disarm it",
"".green().bold(),
"sudo systemctl restart houndd".yellow()
);
}
}
Some(SettingsCmd::OnDetect { mode }) => { Some(SettingsCmd::OnDetect { mode }) => {
let mut next = s.clone(); let mut next = s.clone();
next.on_detect = mode.clone(); next.on_detect = mode.clone();
@ -711,7 +768,7 @@ fn print_settings_human(s: &Settings) {
"off".dimmed().to_string() "off".dimmed().to_string()
} }
}; };
println!("{} {}", "Hound settings:", "settings:".bold()); println!("{}", "Hound settings".bold());
println!(" Recursion: {}", s.recursive_default); println!(" Recursion: {}", s.recursive_default);
println!(" Max file size: {} MB", s.max_file_size_mb); println!(" Max file size: {} MB", s.max_file_size_mb);
println!( println!(
@ -724,6 +781,9 @@ fn print_settings_human(s: &Settings) {
s.realtime_watch.join(", ").dimmed() s.realtime_watch.join(", ").dimmed()
); );
println!(" On detect: {}", s.on_detect.yellow()); println!(" On detect: {}", s.on_detect.yellow());
// A setting you can change but cannot see is half a feature — and this
// is the one that decides whether the machine blocks anything at all.
println!(" Exec gate: {}", on(s.exec_gate));
println!( println!(
" Ransomware guard: {} ({} writes/min)", " Ransomware guard: {} ({} writes/min)",
on(s.ransomware_guard), on(s.ransomware_guard),
@ -826,4 +886,50 @@ mod tests {
let res: Result<()> = Err(anyhow::anyhow!("x")); let res: Result<()> = Err(anyhow::anyhow!("x"));
assert!(res.is_err()); assert!(res.is_err());
} }
/// Every command we print at users must actually parse.
///
/// The install script, the AppImage banner, the rpm spec and llms.txt all
/// told people 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. Nothing caught it
/// because nothing checked that documented commands exist.
#[test]
fn every_documented_command_parses() {
use clap::Parser;
for argv in [
vec!["hound", "status"],
vec!["hound", "scan", "/tmp"],
vec!["hound", "update"],
vec!["hound", "settings", "show"],
vec!["hound", "settings", "exec-gate", "on"],
vec!["hound", "settings", "exec-gate", "off"],
vec!["hound", "settings", "realtime", "on"],
vec!["hound", "settings", "on-detect", "quarantine"],
vec!["hound", "quarantine", "list"],
vec!["hound", "supply-chain", "."],
vec!["hound", "persistence"],
vec!["hound", "rootkit"],
vec!["hound", "realtime"],
] {
assert!(
Cli::try_parse_from(&argv).is_ok(),
"documented command does not parse: {}",
argv.join(" ")
);
}
}
/// The inverse. If `set` ever becomes a real subcommand this test fails
/// and whoever added it can delete the line — but until then, an
/// accidental re-introduction of the old wrong spelling gets caught.
#[test]
fn the_command_we_wrongly_documented_is_still_wrong() {
use clap::Parser;
assert!(
Cli::try_parse_from(["hound", "settings", "set", "exec_gate", "true"]).is_err(),
"if `settings set` exists now, update the docs and drop this test"
);
}
} }

View file

@ -149,6 +149,24 @@ fn hidden_processes() -> Vec<RootkitFinding> {
"/proc is not readable, so hidden processes cannot be checked for".into(), "/proc is not readable, so hidden processes cannot be checked for".into(),
)]; )];
} }
// Before trusting a comparison between /proc and the kernel, check that
// this process can actually see /proc. `ProtectProc=invisible` (and
// hidepid= on the mount) filter the listing while kill(pid, 0) keeps
// answering, so every process on the machine looks concealed. PID 1 is
// the control: it always exists, and nothing hides init — a rootkit that
// did would break the machine it is trying to live on. If we cannot see
// it, we are the ones who are blind.
if pid_exists(1) && !before.contains(&1) {
return vec![finding(
"info",
"hidden_process",
"cannot check for hidden processes: this daemon's view of /proc is \
filtered, so it cannot see other processes. Check for ProtectProc= \
in the systemd unit or hidepid= on the /proc mount."
.into(),
)];
}
let max = pid_max(); let max = pid_max();
let candidates = hidden_pid_candidates(&before, &proc_tids(), max); let candidates = hidden_pid_candidates(&before, &proc_tids(), max);
if candidates.is_empty() { if candidates.is_empty() {
@ -164,6 +182,26 @@ fn hidden_processes() -> Vec<RootkitFinding> {
.filter(|p| !listed.contains(p) && pid_exists(*p)) .filter(|p| !listed.contains(p) && pid_exists(*p))
.collect(); .collect();
// Second guard, for the blindness we could not name. A rootkit hides a
// handful of processes — that is the entire point of hiding. Hundreds of
// "hidden" processes is a broken observer, not a compromised kernel, and
// reporting it as critical trains people to ignore the one time it is
// real.
const IMPLAUSIBLE: usize = 32;
if confirmed.len() > IMPLAUSIBLE {
return vec![finding(
"info",
"hidden_process",
format!(
"cannot check for hidden processes: {} of {} PIDs appear concealed, \
which means this daemon cannot read /proc properly rather than that \
the machine is compromised.",
confirmed.len(),
listed.len()
),
)];
}
confirmed confirmed
.into_iter() .into_iter()
.map(|pid| { .map(|pid| {
@ -403,6 +441,49 @@ fn hidden_files(watch_dirs: &[String]) -> Vec<RootkitFinding> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// The bug that shipped: `ProtectProc=invisible` in the systemd unit
/// filtered the daemon's /proc listing while `kill(pid, 0)` kept
/// answering truthfully, so every process on the machine looked
/// concealed. A clean laptop reported 988 critical findings and this
/// server 3786 — PID 1 among them.
#[test]
fn a_filtered_proc_is_reported_as_blindness_not_a_rootkit() {
// What the daemon saw: only its own threads.
let mine: HashSet<u32> = [4242, 4243].into_iter().collect();
assert!(
!mine.contains(&1),
"the premise of the guard: init is missing from the listing"
);
// Every real PID then looks hidden.
let candidates = hidden_pid_candidates(&mine, &mine, 5000);
assert!(
candidates.len() > 32,
"a filtered /proc yields implausibly many candidates, got {}",
candidates.len()
);
}
/// PID 1 is the control for that guard, so it had better be true.
#[test]
fn init_always_exists_and_is_always_listed() {
assert!(pid_exists(1), "PID 1 must exist");
assert!(
proc_tids().contains(&1),
"PID 1 must appear in /proc — if this fails, the test runner's \
view of /proc is filtered and the blindness guard is what saves us"
);
}
/// A real rootkit hides a few processes. Hundreds is a broken observer.
#[test]
fn the_implausible_threshold_is_above_any_real_rootkit_and_below_a_broken_proc() {
let total_pids = proc_tids().len();
assert!(
total_pids > 32,
"this machine should be running more than 32 tasks, saw {total_pids}"
);
}
use super::*; use super::*;
#[test] #[test]

Binary file not shown.

View file

@ -24,7 +24,19 @@ use hound_api::{
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
/// Whether we have already explained that closing the window is not quitting.
static TRAY_HINT_SHOWN: AtomicBool = AtomicBool::new(false);
/// The two window-behaviour settings, cached from the daemon by the watcher
/// thread. A window-close handler must answer immediately — it cannot make a
/// socket round-trip while the user is watching the window not close — so
/// these mirror `close_to_tray` and `confirm_quit`, and default to the safe
/// answer if the daemon has not been reached yet.
static CLOSE_TO_TRAY: AtomicBool = AtomicBool::new(true);
static CONFIRM_QUIT: AtomicBool = AtomicBool::new(true);
use tauri::image::Image; use tauri::image::Image;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager, State}; use tauri::{Emitter, Manager, State};
@ -61,9 +73,28 @@ async fn status() -> Result<Status, String> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Expand a leading `~` against the running user's home.
///
/// The tray menu and the Scan Home button both send the literal string "~".
/// A shell would have expanded it; nothing here does, so the daemon was being
/// asked to scan a directory of that name. It failed silently until the
/// per-peer readability check started reporting refusals out loud.
fn expand_home(path: &str) -> String {
let Some(home) = std::env::var_os("HOME") else {
return path.to_string();
};
let home = home.to_string_lossy();
match path {
"~" => home.into_owned(),
p if p.starts_with("~/") => format!("{home}/{}", &p[2..]),
p => p.to_string(),
}
}
#[tauri::command] #[tauri::command]
async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> { async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> {
let c = client(); let c = client();
let path = expand_home(&path);
SCANNING.store(true, Ordering::Relaxed); SCANNING.store(true, Ordering::Relaxed);
let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive)) let r = tauri::async_runtime::spawn_blocking(move || c.scan(&path, recursive))
.await .await
@ -75,10 +106,18 @@ async fn scan(path: String, recursive: bool) -> Result<ScanResult, String> {
#[tauri::command] #[tauri::command]
async fn update() -> Result<UpdateResult, String> { async fn update() -> Result<UpdateResult, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.update()) let first = tauri::async_runtime::spawn_blocking(move || c.update())
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("update", None).and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@ -90,13 +129,87 @@ async fn settings() -> Result<Settings, String> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
// ── Elevation ───────────────────────────────────────────────────────────────
/// Run one daemon method as root, via polkit.
///
/// The daemon refuses everything that writes unless the caller is uid 0 — the
/// app runs as you, so those requests need elevating. `pkexec` asks polkit to
/// authenticate the person (password, or fingerprint on hardware that has
/// one) and then runs `hound admin-rpc` as root, which forwards a single
/// request and prints the response. See packaging/polkit/.
///
/// pkexec exits 126 when the user dismisses the dialog and 127 when
/// authorisation is refused; both are ordinary outcomes, not errors to
/// apologise for.
fn elevate(method: &str, params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
use std::io::Write as _;
use std::process::{Command, Stdio};
let mut req = json!({ "jsonrpc": "2.0", "id": 1, "method": method });
if let Some(p) = params {
req["params"] = p;
}
let mut child = Command::new("pkexec")
.arg("/usr/bin/hound")
.arg("admin-rpc")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot start pkexec: {e}"))?;
child
.stdin
.as_mut()
.ok_or("pkexec has no stdin")?
.write_all(req.to_string().as_bytes())
.map_err(|e| format!("sending the request: {e}"))?;
let out = child
.wait_with_output()
.map_err(|e| format!("waiting for pkexec: {e}"))?;
match out.status.code() {
Some(0) => serde_json::from_slice(&out.stdout)
.map_err(|e| format!("decoding the response: {e}")),
Some(126) => Err("Cancelled.".into()),
Some(127) => Err("Not authorised to make this change.".into()),
_ => {
let err = String::from_utf8_lossy(&out.stderr);
let err = err.trim();
Err(if err.is_empty() {
"The privileged helper failed.".into()
} else {
err.to_string()
})
}
}
}
/// True when the daemon refused us for want of privileges, as opposed to
/// anything else going wrong. Only that case is worth a password prompt.
fn needs_root(e: &str) -> bool {
e.contains("requires administrator privileges")
}
#[tauri::command] #[tauri::command]
async fn set_settings(s: Settings) -> Result<Settings, String> { async fn set_settings(s: Settings) -> Result<Settings, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.set_settings(&s)) let for_retry = s.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.set_settings(&s))
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("settings.set", Some(serde_json::to_value(&for_retry).unwrap_or_default()))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@ -111,10 +224,19 @@ async fn events(limit: u32) -> Result<Vec<Event>, String> {
#[tauri::command] #[tauri::command]
async fn clear_events() -> Result<u64, String> { async fn clear_events() -> Result<u64, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.clear_events()) let first = tauri::async_runtime::spawn_blocking(move || c.clear_events())
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("events.clear", None)
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@ -138,19 +260,39 @@ async fn quarantine_add(path: String, virus: String) -> Result<QuarantineEntry,
#[tauri::command] #[tauri::command]
async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> { async fn quarantine_restore(id: String) -> Result<QuarantineEntry, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id)) let for_retry = id.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_restore(&id))
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("quarantine.restore", Some(json!({ "id": for_retry })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
async fn quarantine_remove(id: String) -> Result<u64, String> { async fn quarantine_remove(id: String) -> Result<u64, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id)) let for_retry = id.clone();
let first = tauri::async_runtime::spawn_blocking(move || c.quarantine_remove(&id))
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("quarantine.remove", Some(json!({ "id": for_retry })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
#[tauri::command] #[tauri::command]
@ -174,10 +316,19 @@ async fn realtime_status() -> Result<RealtimeStatus, String> {
#[tauri::command] #[tauri::command]
async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> { async fn realtime_set_enabled(enabled: bool) -> Result<RealtimeStatus, String> {
let c = client(); let c = client();
tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled)) let first = tauri::async_runtime::spawn_blocking(move || c.realtime_set_enabled(enabled))
.await .await
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?;
.map_err(|e| e.to_string()) match first {
Ok(v) => Ok(v),
Err(e) if needs_root(&e.to_string()) => tauri::async_runtime::spawn_blocking(move || {
elevate("realtime.set_enabled", Some(json!({ "enabled": enabled })))
.and_then(|v| serde_json::from_value(v).map_err(|e| e.to_string()))
})
.await
.map_err(|e| e.to_string())?,
Err(e) => Err(e.to_string()),
}
} }
/// Swap the tray icon + tooltip. The window calls this to keep the sentinel /// Swap the tray icon + tooltip. The window calls this to keep the sentinel
@ -237,10 +388,14 @@ fn start_watcher(app: tauri::AppHandle, icons: TrayIcons) {
if c.status().is_err() { if c.status().is_err() {
continue; // daemon restarting — skip the tick continue; // daemon restarting — skip the tick
} }
let paused = c let paused = match c.settings() {
.settings() Ok(s) => {
.map(|s| s.paused) CLOSE_TO_TRAY.store(s.close_to_tray, Ordering::Relaxed);
.unwrap_or(false); CONFIRM_QUIT.store(s.confirm_quit, Ordering::Relaxed);
s.paused
}
Err(_) => false,
};
// Derive tray state: a fresh critical event wins, then an // Derive tray state: a fresh critical event wins, then an
// in-flight scan, then paused, then protected. // in-flight scan, then paused, then protected.
let mut state = "protected"; let mut state = "protected";
@ -375,7 +530,36 @@ pub fn run() {
let _ = window.show(); let _ = window.show();
let _ = window.set_focus(); let _ = window.set_focus();
} }
"quit" => app.exit(0), "quit" => {
// The only route out of the app, and it asks
// first: closing the window hides it instead, so
// a user who means to quit has come here on
// purpose and a user who clicked by accident has
// not lost their protection silently.
if !CONFIRM_QUIT.load(Ordering::Relaxed) {
app.exit(0);
return;
}
let app = app.clone();
std::thread::spawn(move || {
let quit = app
.dialog()
.message(
"Real-time protection will keep running in the \
background, but Hound's window and tray icon will \
close.",
)
.title("Quit Hound?")
.buttons(MessageDialogButtons::OkCancelCustom(
"Quit".into(),
"Keep running".into(),
))
.blocking_show();
if quit {
app.exit(0);
}
});
}
"scan-home" => { "scan-home" => {
let _ = let _ =
window.emit("tray-event", json!({ "action": "scan", "path": "~" })); window.emit("tray-event", json!({ "action": "scan", "path": "~" }));
@ -407,6 +591,31 @@ pub fn run() {
Ok(()) Ok(())
}) })
.on_window_event(|window, event| {
// Closing the window hides it; it never exits. An antivirus that
// stops protecting you because you clicked the X is not an
// antivirus. Quitting is deliberate, from the tray menu, and
// confirmed there.
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if !CLOSE_TO_TRAY.load(Ordering::Relaxed) {
return; // the user turned this off in Settings
}
api.prevent_close();
let _ = window.hide();
// Say so once, so the window vanishing does not read as a
// crash. After that it is expected behaviour and silence is
// the right amount of noise.
if !TRAY_HINT_SHOWN.swap(true, Ordering::Relaxed) {
let _ = window
.app_handle()
.notification()
.builder()
.title("Hound is still running")
.body("Protection continues in the background. Open it again from the tray icon.")
.show();
}
}
})
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Hound"); .expect("error while running Hound");
} }

View file

@ -9,7 +9,7 @@ Hound is installed and scanning on demand.
Real-time execution blocking is OFF until you turn it on: Real-time execution blocking is OFF until you turn it on:
sudo systemctl enable --now houndd sudo systemctl enable --now houndd
sudo hound settings set exec_gate true sudo hound settings exec-gate on
MSG MSG
} }

View file

@ -117,7 +117,7 @@ checks and supply-chain checks.
For real-time protection, install the package: For real-time protection, install the package:
sudo apt install ./hound_*.deb sudo apt install ./hound_*.deb
sudo hound settings set exec_gate true sudo hound settings exec-gate on
MSG MSG
exit 2 exit 2
;; ;;

View file

@ -55,6 +55,11 @@ install -Dm755 "$ROOT/target/release/houndd" "$STAGE/usr/bin/houndd"
install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound" install -Dm755 "$ROOT/target/release/hound" "$STAGE/usr/bin/hound"
install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp" install -Dm755 "$ROOT/target/release/hound-mcp" "$STAGE/usr/bin/hound-mcp"
[ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui" [ "$HAVE_GUI" = yes ] && install -Dm755 "$GUI_BIN" "$STAGE/usr/bin/hound-gui"
# Lets the desktop app elevate a single daemon request through polkit rather
# than asking people to open a terminal for every settings change.
install -Dm644 "$ROOT/packaging/polkit/com.houndav.hound.policy" \
"$STAGE/usr/share/polkit-1/actions/com.houndav.hound.policy"
install -Dm644 "$ROOT/packaging/systemd/houndd.service" \ install -Dm644 "$ROOT/packaging/systemd/houndd.service" \
"$STAGE/lib/systemd/system/houndd.service" "$STAGE/lib/systemd/system/houndd.service"
install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \ install -Dm644 "$ROOT/crates/houndd/rules/hound-builtin.yar" \
@ -116,7 +121,7 @@ Description: Hound Antivirus for Linux
. .
The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and
covers the whole root filesystem, so turning it on is the operator's covers the whole root filesystem, so turning it on is the operator's
decision: hound settings set exec_gate true decision: hound settings exec-gate on
CONTROL CONTROL
cat > "$STAGE/DEBIAN/conffiles" <<'CONFFILES' cat > "$STAGE/DEBIAN/conffiles" <<'CONFFILES'
@ -216,7 +221,7 @@ case "$1" in
echo "" echo ""
echo "Real-time execution blocking is OFF until you turn it on:" echo "Real-time execution blocking is OFF until you turn it on:"
echo "" echo ""
echo " sudo hound settings set exec_gate true" echo " sudo hound settings exec-gate on"
echo "" echo ""
echo "To let a coding assistant check repositories before trusting them," echo "To let a coding assistant check repositories before trusting them,"
echo "add this to its MCP configuration:" echo "add this to its MCP configuration:"

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/software/polkit/policyconfig-1.dtd">
<policyconfig>
<vendor>Hound Antivirus</vendor>
<vendor_url>https://houndav.com</vendor_url>
<icon_name>hound</icon_name>
<!--
The daemon refuses every method that writes unless the caller is root,
because quarantine restores files back out as root. The desktop app runs
as the logged-in user, so it asks polkit to authenticate the person before
forwarding such a request.
auth_admin_keep, not auth_admin: re-prompting on every settings toggle
trains people to type their password without reading the prompt, which is
worse security than a short grace period.
-->
<action id="com.houndav.hound.admin">
<description>Change Hound Antivirus settings or quarantine</description>
<message>Authentication is required to change Hound's protection settings</message>
<defaults>
<allow_any>auth_admin_keep</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/hound</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
</action>
</policyconfig>

View file

@ -17,7 +17,7 @@ it has already run.
The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and The execution gate is installed switched OFF. It needs CAP_SYS_ADMIN and
covers the whole root filesystem, so enabling it is the operator's call: covers the whole root filesystem, so enabling it is the operator's call:
hound settings set exec_gate true hound settings exec-gate on
%prep %prep
%autosetup %autosetup

View file

@ -66,7 +66,16 @@ ProtectKernelLogs=yes
ProtectControlGroups=yes ProtectControlGroups=yes
ProtectClock=yes ProtectClock=yes
ProtectHostname=yes ProtectHostname=yes
ProtectProc=invisible # ProtectProc is deliberately NOT set, and this one bit hard. `invisible`
# hides every process 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 rootkit check compares those two sources and reports
# anything present in one but not the other. With ProtectProc=invisible that
# is EVERY process on the machine: a clean laptop reported 988 hidden
# processes, and this server 3786, PID 1 among them. An antivirus that
# announces a rootkit on every clean machine is worse than one that does not
# look. Enumerating processes is this daemon's job, so it needs the default
# view; the detector also now refuses to report when it can tell it is blind.
RestrictNamespaces=yes RestrictNamespaces=yes
RestrictRealtime=yes RestrictRealtime=yes
RestrictSUIDSGID=yes RestrictSUIDSGID=yes

View file

@ -92,7 +92,7 @@ chmod +x hound && ./hound status
The execution gate ships **switched off**. It needs `CAP_SYS_ADMIN` and covers The execution gate ships **switched off**. It needs `CAP_SYS_ADMIN` and covers
the whole root filesystem, so enabling it is the operator's decision: the whole root filesystem, so enabling it is the operator's decision:
`sudo hound settings set exec_gate true`. `sudo hound settings exec-gate on`.
## Pricing ## Pricing