keyhog 0.5.50

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
Documentation
//! `keyhog daemon {start,stop,status}` - manage a long-lived
//! scanner process that amortizes the ~3 s `CompiledScanner::compile`
//! cold start across many client invocations (pre-commit hooks, IDE
//! save handlers, CI per-commit pipelines).

use crate::args::DaemonArgs;
use crate::daemon::client;
use crate::daemon::protocol::{response_kind, Request, Response};
use crate::daemon::server::{self, default_socket_path};
use crate::style;
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;

pub(crate) async fn run(args: DaemonArgs) -> Result<ExitCode> {
    match args.action {
        crate::args::DaemonAction::Start {
            socket,
            detectors,
            detectors_cli_explicit,
            cache_dir,
            backend,
            request_timeout_secs,
            mass,
            mass_gpu_primary,
        } => {
            start(
                socket,
                detectors,
                detectors_cli_explicit,
                cache_dir,
                backend,
                request_timeout_secs,
                mass,
                mass_gpu_primary,
            )
            .await
        }
        crate::args::DaemonAction::Stop { socket } => stop(socket).await,
        crate::args::DaemonAction::Status { socket } => status(socket).await,
    }
}

async fn start(
    socket: Option<PathBuf>,
    detectors_dir: PathBuf,
    detectors_cli_explicit: bool,
    cache_dir: Option<PathBuf>,
    backend: Option<String>,
    request_timeout_secs: u64,
    mass: bool,
    mass_gpu_primary: bool,
) -> Result<ExitCode> {
    crate::runtime_preflight::validate_scan_runtime_config()?;
    crate::orchestrator_config::validate_explicit_detector_path(
        &detectors_dir,
        detectors_cli_explicit,
    )?;
    let detectors_dir = crate::orchestrator_config::auto_discover_detectors(&detectors_dir)?;
    crate::orchestrator_config::configure_hyperscan_cache_dir(cache_dir)?;
    let backend_override = crate::orchestrator_config::parse_backend_override(backend.as_deref())?;
    let gpu_policy =
        crate::orchestrator_config::gpu_runtime_policy_for_backend_override(backend_override)?;
    keyhog_scanner::gpu::set_gpu_runtime_policy(gpu_policy);
    if gpu_policy == keyhog_scanner::gpu::GpuRuntimePolicy::Required {
        keyhog_scanner::gpu::require_gpu_preflight()
            .map_err(crate::orchestrator::daemon_gpu_preflight_failure)?;
    }

    let socket = socket.unwrap_or_else(default_socket_path); // LAW10: absent config => documented default; Tier-A knob, recall-irrelevant
                                                             // Use the same load-or-embedded fallback that `scan`, `watch`, `scan-system`
                                                             // and `explain` go through. Before this, `daemon start` ran
                                                             // `keyhog_core::load_detectors(&"detectors")` directly and bailed with
                                                             // `failed to read detector file detectors: No such file or directory`
                                                             // on every install where the user hadn't `cd`'d into a checked-out
                                                             // repo - which is every install via `install.sh` / `cargo install`.
    let detectors = crate::orchestrator_config::load_detectors_or_embedded(&detectors_dir)
        .with_context(|| {
            format!(
                "daemon start: load detectors from {}",
                detectors_dir.display()
            )
        })?;
    let options = server::ServerOptions {
        request_read_timeout: Duration::from_secs(request_timeout_secs),
        mass_service: mass,
        mass_gpu_primary_required: mass_gpu_primary,
    };
    server::run_with_backend_override(socket, detectors, options, backend_override).await?;
    Ok(ExitCode::SUCCESS)
}

async fn stop(socket: Option<PathBuf>) -> Result<ExitCode> {
    let socket = socket.unwrap_or_else(default_socket_path); // LAW10: absent config => documented default; Tier-A knob, recall-irrelevant
                                                             // `connect_any_version`, not `connect`: a daemon left running across a
                                                             // `keyhog update` reports an older keyhog version, and the whole point of
                                                             // `daemon stop` is to clear exactly that stale daemon. The strict
                                                             // version-gated `connect` (used by the scan route) would REFUSE to talk to
                                                             // it, stranding the stale process; `stop` must still be able to shut it down.
    let mut conn = client::connect_any_version(&socket)
        .await
        .with_context(|| {
            format!(
                "daemon stop: no daemon at {} (already stopped?)",
                socket.display()
            )
        })?;
    match conn.round_trip(&Request::Shutdown).await? {
        Response::Shutdown => {
            eprintln!("keyhog daemon stopped");
            Ok(ExitCode::SUCCESS)
        }
        other => {
            anyhow::bail!(
                "daemon stop: protocol mismatch (got {}). Shutdown was not confirmed, and the \
                 incompatible daemon socket was left untouched. Stop the daemon with the matching \
                 KeyHog version or the service manager that started it before starting a replacement.",
                response_kind(&other)
            )
        }
    }
}

async fn status(socket: Option<PathBuf>) -> Result<ExitCode> {
    let socket = socket.unwrap_or_else(default_socket_path); // LAW10: absent config => documented default; Tier-A knob, recall-irrelevant
                                                             // `connect_any_version`: `status` is diagnostic, an operator inspecting a
                                                             // daemon left running across an upgrade NEEDS to see it (so they can decide
                                                             // to restart it), not get a refusal. The strict version-gated `connect`
                                                             // would hide the very stale daemon the operator is trying to find.
    let mut conn = client::connect_any_version(&socket)
        .await
        .with_context(|| {
            format!(
                "daemon status: no daemon at {} (start one with `keyhog daemon start`)",
                socket.display()
            )
        })?;
    // Surface staleness LOUDLY: a daemon left running across a `keyhog update`
    // serves an OLDER detector corpus. The scan route already refuses it
    // (`connect` fails closed), but an operator running `status` must SEE that
    // the daemon is stale, otherwise the healthy-looking uptime line hides the
    // very reason their scans are silently routed in-process.
    let mut stale = conn.is_stale();
    let daemon_version = conn.daemon_version().to_string();
    let backend_policy = conn.backend_policy().to_string();
    let mass_service = conn.is_mass_service();
    let mut stale_reason = conn.stale_reason().map(str::to_string);
    let hello_warm_backend = conn
        .warm_backend_status()
        .context("daemon status: Hello omitted required warm-backend status")?
        .clone();
    match conn.round_trip(&Request::Health).await? {
        Response::Health {
            uptime_secs,
            scans_served,
            active_scans,
            detector_count,
            backend_recoveries,
            last_backend_fault,
            warm_backend,
        } => {
            if warm_backend.daemon_generation != hello_warm_backend.daemon_generation {
                anyhow::bail!(
                    "daemon status: daemon generation changed between Hello ({}) and Health ({}). Retry status; if it recurs, restart with `keyhog daemon stop && keyhog daemon start`.",
                    hello_warm_backend.daemon_generation,
                    warm_backend.daemon_generation
                );
            }
            if warm_backend.identity != hello_warm_backend.identity
                || warm_backend.required_backends != hello_warm_backend.required_backends
            {
                anyhow::bail!(
                    "daemon status: warm-route identity or required backend set changed within daemon generation {}; restart with `keyhog daemon stop && keyhog daemon start`",
                    warm_backend.daemon_generation
                );
            }
            let exact_mismatches = client::current_warm_backend_mismatches(&warm_backend)?;
            if !exact_mismatches.is_empty() {
                stale = true;
                let exact_reason = exact_mismatches.join("; ");
                stale_reason = Some(match stale_reason.take() {
                    Some(control_reason) if control_reason != exact_reason => {
                        format!("{control_reason}; {exact_reason}")
                    }
                    Some(control_reason) => control_reason,
                    None => exact_reason,
                });
            }
            if warm_backend.ready {
                println!(
                    "warm backend: ready · generation {} · engine {} · binary {} · detectors {} · config {} · GPU artifact {}",
                    warm_backend.daemon_generation,
                    warm_backend.identity.engine,
                    warm_backend.identity.binary_sha256,
                    warm_backend.identity.detector_rules_digest,
                    warm_backend.identity.config_digest,
                    match warm_backend.identity.gpu_artifact.as_deref() {
                        Some(artifact) => artifact,
                        None => "none",
                    }
                );
            } else {
                let (reason, repair) = match (
                    warm_backend.reason.as_deref(),
                    warm_backend.repair_command.as_deref(),
                ) {
                    (Some(reason), Some(repair)) => (reason, repair),
                    _ => anyhow::bail!(
                        "daemon status: warm-backend status is internally inconsistent; restart with `keyhog daemon stop && keyhog daemon start`"
                    ),
                };
                println!(
                    "warm backend: not ready · generation {} · {reason} · repair `{repair}`",
                    warm_backend.daemon_generation
                );
            }
            println!(
                "keyhog daemon: uptime {}s · {} scans served · {} active · {} detectors",
                uptime_secs, scans_served, active_scans, detector_count
            );
            if mass_service {
                println!(
                    "scan scope: bounded directory, Git, archive, binary, remote, and cloud \
                     batches via --daemon=mass; warm stdin/single-file requests remain available. \
                     Baseline, Merkle state, verification, lockdown, and per-request scanner policy \
                     remain in-process."
                );
            } else {
                println!(
                    "scan scope: warm stdin/single-file requests only; start with --mass for \
                     bounded client-acquired source batches."
                );
            }
            if backend_policy == "autoroute" {
                println!("backend policy: autoroute (persisted warm-route evidence)");
            } else if backend_policy == "autoroute-recovery" {
                println!(
                    "backend policy: autoroute invalid (scalar correctness recovery; run `keyhog calibrate-autoroute`)"
                );
            } else if backend_policy == "autoroute-degraded" {
                println!(
                    "backend policy: autoroute degraded (one or more workload routes quarantined; affected requests use scalar correctness recovery; run `keyhog calibrate-autoroute`)"
                );
            } else {
                println!(
                    "backend policy: forced {backend_policy} (daemon startup diagnostic override)"
                );
            }
            if let Some(fault) = last_backend_fault {
                let state = if fault.failed_backend == "autoroute-invalid" {
                    "Autoroute remains invalid until recalibration."
                } else {
                    "The affected route is quarantined until recalibration."
                };
                println!(
                    "backend health: {} recovered request(s); last fault {} recovered {} byte(s) through {}. {}",
                    backend_recoveries,
                    fault.failed_backend,
                    fault.recovered_bytes,
                    fault.recovery_backend,
                    state,
                );
            } else if backend_policy == "autoroute-recovery" {
                println!(
                    "backend health: autoroute evidence invalid; requests will report complete scalar recovery"
                );
            } else if backend_policy == "autoroute-degraded" {
                println!(
                    "backend health: persisted autoroute quarantine loaded; affected requests will report complete scalar recovery"
                );
            } else {
                println!("backend health: no recovered runtime faults");
            }
            if stale {
                let palette = style::for_stderr();
                let reason = match stale_reason.as_deref() {
                    Some(reason) => reason,
                    None => anyhow::bail!(
                        "daemon status: client marked the daemon stale without an exact readiness or identity mismatch"
                    ),
                };
                eprintln!(
                    "{} this daemon's warm-route readiness/identity is not compatible with the client \
                     (daemon keyhog {}, client {}; {}): scan connections refuse it; \
                     `--daemon=auto` runs in process and `--daemon=on` fails until you restart it: \
                     `keyhog daemon stop && keyhog daemon start`.",
                    style::warn("WARN", &palette),
                    daemon_version,
                    env!("CARGO_PKG_VERSION"),
                    reason,
                );
            }
            Ok(ExitCode::SUCCESS)
        }
        other => anyhow::bail!(
            "daemon status: protocol mismatch (got {}). Restart with \
             `keyhog daemon stop && keyhog daemon start` to clear stuck state.",
            response_kind(&other)
        ),
    }
}