use crate::args::{DaemonMode, ScanArgs};
#[cfg(unix)]
use crate::exit_codes::{EXIT_CREDENTIALS_FOUND, EXIT_LIVE_CREDENTIALS, EXIT_SOURCE_FAILED};
#[cfg(unix)]
use crate::daemon::client;
#[cfg(unix)]
use crate::daemon::protocol::{Request, RequiredOption, Response, SourceCoverageGaps};
#[cfg(unix)]
use crate::daemon::server::default_socket_path;
use crate::orchestrator::ScanOrchestrator;
use anyhow::{bail, Result};
#[cfg(unix)]
use anyhow::Context;
#[cfg(unix)]
use keyhog_core::{RawMatch, RuleSuppressor, ScanCompletionStatus, VerifiedFinding};
#[cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::ExitCode;
pub(crate) async fn run(args: ScanArgs) -> Result<ExitCode> {
crate::runtime_preflight::validate_scan_runtime_config()?;
guard_multi_root_combinations(&args)?;
if args.daemon_mode() == DaemonMode::Off && args.daemon_socket.is_some() {
bail!("`--daemon-socket` cannot be combined with `--daemon=off`; remove the socket or choose `--daemon=auto|on`");
}
#[cfg(not(unix))]
{
let mode = args.daemon_mode();
if args.daemon.is_some() && mode.may_use_daemon_transport() {
let requested = if mode == DaemonMode::Auto {
"auto"
} else {
"on"
};
bail!(
"`--daemon={requested}` is a unix-only mode (the daemon serves scans \
over a Unix-domain socket). Drop the flag to run \
in-process, or pass `--daemon=off` to be explicit."
);
}
let orchestrator = ScanOrchestrator::new(args)?;
return orchestrator.run().await;
}
#[cfg(unix)]
{
let mode = args.daemon_mode();
let daemon_reachable = mode == DaemonMode::On
|| (mode != DaemonMode::Off && effective_daemon_socket(&args).exists());
if !daemon_reachable {
let orchestrator = ScanOrchestrator::new(args)?;
return orchestrator.run().await;
}
let mut policy = EffectivePolicy::resolve(&args);
match daemon_route(&args, &policy) {
DaemonRoute::Required => run_via_daemon(&mut policy.effective_args).await,
DaemonRoute::Opportunistic => {
match acquire_via_daemon(&mut policy.effective_args).await {
Ok(scan) => finish_daemon_scan(scan, &policy.effective_args),
Err(e) => {
if policy.effective_args.daemon_mode() == DaemonMode::Auto {
let palette = crate::style::for_stderr();
eprintln!(
"{}: daemon auto route unavailable ({e:#}); running in-process scanner",
crate::style::warn("keyhog", &palette)
);
}
tracing::debug!(
error = %e,
"daemon auto route unavailable; running in-process scanner"
);
let mut retry_args = args.clone();
retry_args.buffered_stdin = policy.effective_args.buffered_stdin.clone();
let orchestrator = ScanOrchestrator::new(retry_args)?;
orchestrator.run().await
}
}
}
DaemonRoute::Rejected(reason) => bail!("{reason}"),
DaemonRoute::Forbidden => {
let orchestrator = ScanOrchestrator::new(args)?;
orchestrator.run().await
}
}
}
}
#[cfg(unix)]
enum DaemonRoute {
Required,
Opportunistic,
Forbidden,
Rejected(String),
}
pub(crate) fn guard_multi_root_combinations(args: &ScanArgs) -> Result<()> {
let roots = args.scan_roots();
if roots.len() <= 1 {
return Ok(());
}
#[cfg(feature = "git")]
if args.git_staged {
let list = roots
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
bail!(
"`--git-staged` resolves staged files from one repository working \
tree, so it cannot span the {n} roots given ({list}).\n\
Run `keyhog scan --git-staged <repo>` once per repository, or drop \
`--git-staged` to walk every root on disk.",
n = roots.len(),
list = list,
);
}
Ok(())
}
#[cfg(unix)]
struct EffectivePolicy {
effective_args: ScanArgs,
min_confidence: Option<f64>,
show_secrets: bool,
#[cfg(feature = "verify")]
verify: bool,
severity: bool,
require_lockdown: bool,
has_config_errors: bool,
custom_aws_canary_accounts: bool,
has_allowlist_config: bool,
has_detector_min_confidence: bool,
}
#[cfg(unix)]
impl EffectivePolicy {
fn resolve(args: &ScanArgs) -> EffectivePolicy {
let mut probe = args.clone();
if probe.path.is_none() {
probe.path = probe.input.first().cloned();
}
let outcome = crate::config::apply_config_file_quiet(&mut probe);
let min_confidence = probe.min_confidence;
let show_secrets = probe.show_secrets;
#[cfg(feature = "verify")]
let verify = probe.verify;
let severity = probe.severity.is_some();
EffectivePolicy {
effective_args: probe,
min_confidence,
show_secrets,
#[cfg(feature = "verify")]
verify,
severity,
require_lockdown: outcome.require_lockdown,
has_config_errors: !outcome.config_errors.is_empty(),
custom_aws_canary_accounts: !outcome.aws_canary_accounts.is_empty(),
has_allowlist_config: outcome.allowlist_file.is_some()
|| outcome.allowlist_require_reason
|| outcome.allowlist_require_approved_by
|| outcome.allowlist_max_expires_days.is_some(),
has_detector_min_confidence: !outcome.detector_min_confidence.is_empty(),
}
}
}
#[cfg(unix)]
fn daemon_route(args: &ScanArgs, policy: &EffectivePolicy) -> DaemonRoute {
let mode = args.daemon_mode();
if mode == DaemonMode::Off {
return DaemonRoute::Forbidden;
}
let forced_on = mode == DaemonMode::On;
#[cfg(feature = "verify")]
if policy.verify {
if let Some(route) = reject_forced_daemon(
forced_on,
"verification requires the in-process verifier; the daemon only returns scanner matches",
) {
return route;
}
return DaemonRoute::Forbidden;
}
if args.baseline.is_some() {
if let Some(route) = reject_forced_daemon(
forced_on,
"--baseline requires the in-process baseline filter; the daemon has no baseline state",
) {
return route;
}
return DaemonRoute::Forbidden;
}
let single_file = match effective_single_file_path(args) {
Ok(path) => path.is_some(),
Err(error) => {
if let Some(route) = reject_forced_daemon(
forced_on,
&format!(
"the daemon single-file route cannot inspect the requested path: {error:#}"
),
) {
return route;
}
return DaemonRoute::Forbidden;
}
};
let primary_sources = usize::from(args.stdin) + usize::from(single_file);
if primary_sources != 1 || has_daemon_incompatible_extra_sources(args) {
if let Some(route) = reject_forced_daemon(
forced_on,
"the daemon only supports exactly one source: --stdin or a single regular file; directories, git, remote, binary, dynamic, and multi-source scans require the in-process scanner",
) {
return route;
}
return DaemonRoute::Forbidden;
}
if args.lockdown
|| policy.require_lockdown
|| policy.show_secrets
|| policy.severity
|| policy.min_confidence.is_some()
|| policy.has_config_errors
|| policy.custom_aws_canary_accounts
|| policy.has_allowlist_config
|| policy.has_detector_min_confidence
|| args.hide_client_safe
{
if let Some(route) = reject_forced_daemon(
forced_on,
"this scan requests filtering, lockdown, secret-output, AWS canary config, allowlist governance, or config policy the daemon cannot enforce",
) {
return route;
}
return DaemonRoute::Forbidden;
}
if let Some(reason) = daemon_incompatible_scan_options(&policy.effective_args) {
if let Some(route) = reject_forced_daemon(forced_on, reason) {
return route;
}
return DaemonRoute::Forbidden;
}
if forced_on {
return DaemonRoute::Required;
}
if effective_daemon_socket(args).exists() {
DaemonRoute::Opportunistic
} else {
DaemonRoute::Forbidden
}
}
#[cfg(unix)]
fn effective_daemon_socket(args: &ScanArgs) -> std::path::PathBuf {
args.daemon_socket
.clone()
.unwrap_or_else(default_socket_path)
}
#[cfg(unix)]
fn reject_forced_daemon(forced_on: bool, reason: &str) -> Option<DaemonRoute> {
forced_on.then(|| {
DaemonRoute::Rejected(format!(
"--daemon=on cannot be honored: {reason}. Drop `--daemon=on`, or pass \
`--daemon=off` to run the in-process scanner explicitly."
))
})
}
#[cfg(unix)]
fn has_daemon_incompatible_extra_sources(args: &ScanArgs) -> bool {
#[cfg(feature = "binary")]
if args.binary {
return true;
}
#[cfg(feature = "git")]
if args.git_blobs.is_some()
|| args.git_diff.is_some()
|| args.git_history.is_some()
|| args.git_staged
{
return true;
}
#[cfg(feature = "github")]
if args.github_org.is_some() {
return true;
}
#[cfg(feature = "gitlab")]
if args.gitlab_group.is_some() {
return true;
}
#[cfg(feature = "bitbucket")]
if args.bitbucket_workspace.is_some() {
return true;
}
#[cfg(feature = "s3")]
if args.s3_bucket.is_some() {
return true;
}
#[cfg(feature = "gcs")]
if args.gcs_bucket.is_some() {
return true;
}
#[cfg(feature = "azure")]
if args.azure_container_url.is_some() {
return true;
}
#[cfg(feature = "docker")]
if args.docker_image.is_some() {
return true;
}
#[cfg(feature = "web")]
if args.url.as_ref().is_some_and(|urls| !urls.is_empty()) {
return true;
}
args.source
.as_ref()
.is_some_and(|sources| !sources.is_empty())
}
#[cfg(unix)]
fn daemon_incompatible_scan_options(args: &ScanArgs) -> Option<&'static str> {
if args.detectors_cli_explicit || args.detectors != PathBuf::from("detectors") {
return Some(
"this scan selects a detector corpus that the precompiled daemon scanner cannot honor",
);
}
if args.fast
|| args.deep
|| args.precision
|| args.no_decode
|| args.no_entropy
|| args.no_entropy_ml_scoring
|| args.no_keyword_low_entropy
|| args.entropy_source_files
|| args.no_unicode_norm
|| args.no_ml
|| args.scan_comments
|| args.benchmark
{
return Some(
"this scan sets scan-mode, engine, or benchmark options that require the in-process scanner",
);
}
if args.backend.is_some()
|| args.autoroute_cache.is_some()
|| args.autoroute_calibrate
|| args.autoroute_gpu
|| args.no_autoroute_gpu
|| args.no_gpu
|| args.require_gpu
|| args.batch_pipeline
|| args.no_batch_pipeline
{
return Some(
"this scan sets backend, GPU, batch-pipeline, or autoroute controls the daemon protocol cannot honor per request",
);
}
if args.decode_depth.is_some()
|| args.decode_size_limit.is_some()
|| args.entropy_threshold.is_some()
|| args.entropy_bpe_max_bytes_per_token.is_some()
|| args.min_secret_len.is_some()
|| args.ml_weight.is_some()
|| args.max_file_size.is_some()
|| args.regex_dfa_limit.is_some()
|| args.gpu_batch_input_limit.is_some()
|| args.cache_dir.is_some()
|| args.ml_threshold.is_some()
{
return Some(
"this scan changes scanner or source-limit configuration that the precompiled daemon scanner cannot honor",
);
}
if args.no_default_excludes || args.exclude_paths.is_some() {
return Some(
"this scan changes path exclusion policy that the daemon single-file route cannot honor",
);
}
if !args.known_prefixes.is_empty()
|| !args.secret_keywords.is_empty()
|| !args.test_keywords.is_empty()
|| !args.placeholder_keywords.is_empty()
{
return Some(
"this scan changes detector confidence vocabulary that the precompiled daemon scanner cannot honor",
);
}
None
}
#[cfg(unix)]
fn effective_single_file_path(args: &ScanArgs) -> Result<Option<&Path>> {
if args.input.len() > 1 {
return Ok(None);
}
let Some(raw) = args
.path
.as_deref()
.or_else(|| args.input.first().map(PathBuf::as_path))
else {
return Ok(None);
};
let meta = std::fs::metadata(raw)
.with_context(|| format!("inspect {} as daemon single-file input", raw.display()))?;
if !meta.is_file() {
return Ok(None);
}
Ok(Some(raw))
}
#[cfg(unix)]
async fn run_via_daemon(args: &mut ScanArgs) -> Result<ExitCode> {
let scan = acquire_via_daemon(args).await?;
finish_daemon_scan(scan, args)
}
#[cfg(unix)]
struct DaemonScan {
matches: Vec<RawMatch>,
source_coverage_gaps: SourceCoverageGaps,
source_bytes_scanned: u64,
wall_start: chrono::DateTime<chrono::Utc>,
}
#[cfg(unix)]
async fn acquire_via_daemon(args: &mut ScanArgs) -> Result<DaemonScan> {
crate::reset_scan_runtime_state();
if args.dogfood {
keyhog_scanner::telemetry::enable_dogfood();
}
let wall_start = chrono::Utc::now();
let socket = effective_daemon_socket(args);
let mut conn = client::connect(&socket).await.with_context(|| {
format!(
"daemon route: connect to {} (start one with `keyhog daemon start{}` or pass --daemon=off)",
socket.display(),
match &args.daemon_socket {
Some(path) => format!(" --socket {}", path.display()),
None => String::new(),
},
)
})?;
let (matches, source_coverage_gaps, source_bytes_scanned) = if args.stdin {
let bytes = read_stdin_bytes(args)?;
let source_bytes_scanned = bytes.len() as u64;
args.buffered_stdin = Some(bytes.clone());
let stdin_cap_bytes = args.limits.to_source_limits().stdin_bytes;
if bytes.len() > stdin_cap_bytes {
bail!(
"daemon route: stdin exceeds {stdin_cap_bytes} byte limit. + Drop `--daemon` to use the streaming in-process path."
);
}
let text = String::from_utf8_lossy(&bytes).into_owned();
let resp = conn
.round_trip(&Request::ScanText {
path: None,
text,
dogfood: args.dogfood,
})
.await?;
let (matches, gaps) = unwrap_scan_results(resp)?;
(matches, gaps, source_bytes_scanned)
} else if let Some(path) = effective_single_file_path(args)? {
let source_bytes_scanned = std::fs::metadata(path)
.with_context(|| format!("stat daemon input {}", path.display()))?
.len();
let working_dir = std::env::current_dir()
.ok() .map(|p| p.to_string_lossy().into_owned());
let resp = conn
.round_trip(&Request::ScanPath {
path: path.to_string_lossy().into_owned(),
working_dir,
dogfood: args.dogfood,
})
.await?;
let (matches, gaps) = unwrap_scan_results(resp)?;
(matches, gaps, source_bytes_scanned)
} else {
bail!(
"daemon route requires either --stdin or a single file path. \
For directory scans, pass `--daemon=off` to use the in-process scanner."
);
};
Ok(DaemonScan {
matches,
source_coverage_gaps,
source_bytes_scanned,
wall_start,
})
}
#[cfg(unix)]
fn finish_daemon_scan(scan: DaemonScan, args: &ScanArgs) -> Result<ExitCode> {
let DaemonScan {
matches,
source_coverage_gaps,
source_bytes_scanned,
wall_start,
} = scan;
let findings = finalize_for_report(matches, args)?;
let report_finished_at = chrono::Utc::now();
let mut report_metadata = crate::reporting::report_metadata_from_scan_run(
args,
wall_start,
report_finished_at,
(report_finished_at - wall_start).num_milliseconds().max(0) as u128,
1,
source_bytes_scanned,
keyhog_core::embedded_detector_count(),
None,
);
if !source_coverage_gaps.is_empty() {
keyhog_sources::merge_skip_count_deltas(&keyhog_sources::SkipCounts {
over_max_size: source_coverage_gaps.over_max_size,
binary: source_coverage_gaps.binary,
excluded: 0,
unreadable: source_coverage_gaps.unreadable,
git_object_unreadable: source_coverage_gaps.git_object_unreadable,
archive_truncated: source_coverage_gaps.archive_truncated,
binary_section_name_unresolved: source_coverage_gaps.binary_section_name_unresolved,
source_truncated: source_coverage_gaps.source_truncated,
structured_source_parse_failures: source_coverage_gaps.structured_source_parse_failures,
archive_duplicate_scan_unavailable: source_coverage_gaps
.archive_duplicate_scan_unavailable,
git_lfs_pointer: source_coverage_gaps.git_lfs_pointer,
});
}
if !source_coverage_gaps.is_empty() {
report_metadata.scan_status = ScanCompletionStatus::Partial;
}
crate::reporting::report_findings_with_metadata(&findings, args, &report_metadata)?;
if args.dogfood {
crate::orchestrator::reporting::dump_dogfood_trace();
}
let fail_gaps = source_coverage_gaps.fail_class_total();
if fail_gaps > 0 {
let palette = crate::style::for_stderr();
eprintln!(
"{}: daemon input coverage was incomplete ({} FAIL-class gap(s), {} total gap(s)); some requested bytes were not scanned.",
crate::style::warn("warning", &palette),
fail_gaps,
source_coverage_gaps.total()
);
}
if findings.is_empty() && fail_gaps > 0 {
let palette = crate::style::for_stderr();
eprintln!(
"{}: not reporting \"clean\" after incomplete daemon input coverage.",
crate::style::fail("error", &palette)
);
Ok(ExitCode::from(EXIT_SOURCE_FAILED))
} else if findings.is_empty() {
Ok(ExitCode::SUCCESS)
} else {
let code = crate::orchestrator::scan_exit_code(&findings);
if code == EXIT_LIVE_CREDENTIALS {
Ok(ExitCode::from(EXIT_LIVE_CREDENTIALS))
} else {
Ok(ExitCode::from(EXIT_CREDENTIALS_FOUND))
}
}
}
#[cfg(unix)]
fn read_stdin_bytes(args: &ScanArgs) -> Result<Vec<u8>> {
use std::io::Read;
let stdin_cap_bytes = args.limits.to_source_limits().stdin_bytes;
let mut buf = Vec::with_capacity(8 * 1024);
std::io::stdin()
.lock()
.take(stdin_cap_bytes.saturating_add(1) as u64)
.read_to_end(&mut buf)
.context("daemon route: reading stdin")?;
Ok(buf)
}
#[cfg(unix)]
fn unwrap_scan_results(resp: Response) -> Result<(Vec<RawMatch>, SourceCoverageGaps)> {
match resp {
Response::ScanResults {
matches,
engine_example_suppressions,
dogfood_events,
static_recovery_rejections,
dogfood_detail_events_dropped,
source_coverage_gaps,
backend_recovery,
..
} => {
keyhog_scanner::telemetry::merge_daemon_aggregates(
&static_recovery_rejections,
dogfood_detail_events_dropped,
)
.map_err(|error| {
anyhow::anyhow!(
"daemon returned incompatible dogfood telemetry: {error}. Restart it with `keyhog daemon stop && keyhog daemon start`, or pass `--daemon=off`."
)
})?;
if engine_example_suppressions > 0 {
keyhog_scanner::telemetry::add_example_suppressions(
engine_example_suppressions as usize,
);
}
if !dogfood_events.is_empty() {
keyhog_scanner::telemetry::append_daemon_events(dogfood_events);
}
if let RequiredOption::Some(recovery) = backend_recovery {
if recovery.failed_backend == "autoroute-invalid" {
let recovery_backend = keyhog_scanner::hw_probe::parse_backend_str(
&recovery.recovery_backend,
)
.ok_or_else(|| {
anyhow::anyhow!(
"daemon returned unknown recovery backend {:?}; restart it with this KeyHog build",
recovery.recovery_backend
)
})?;
let recovered_range_count = recovery.recovered_ranges.len();
let recovered_chunks = recovery.recovered_ranges.iter().try_fold(
std::collections::BTreeSet::new(),
|mut chunks, range| {
if range.byte_end < range.byte_start {
bail!("daemon returned an invalid autoroute recovery range; restart it with this KeyHog build");
}
chunks.insert(range.chunk_index);
Ok::<_, anyhow::Error>(chunks)
},
)?.len();
let recovered_bytes = recovery
.recovered_ranges
.iter()
.map(|range| (range.byte_end - range.byte_start) as u64)
.sum::<u64>();
if recovered_chunks != recovery.recovered_chunks
|| recovered_bytes != recovery.recovered_bytes
{
bail!("daemon returned inconsistent autoroute-recovery totals; restart it with this KeyHog build");
}
crate::orchestrator::record_completed_remote_autoroute_state_recovery(
recovery_backend,
recovered_range_count,
recovered_chunks,
recovered_bytes,
recovery.reason,
);
return Ok((matches, source_coverage_gaps));
}
let failed_backend = keyhog_scanner::hw_probe::parse_backend_str(
&recovery.failed_backend,
)
.ok_or_else(|| {
anyhow::anyhow!(
"daemon returned unknown failed backend {:?}; restart it with this KeyHog build",
recovery.failed_backend
)
})?;
let recovery_backend = keyhog_scanner::hw_probe::parse_backend_str(
&recovery.recovery_backend,
)
.ok_or_else(|| {
anyhow::anyhow!(
"daemon returned unknown recovery backend {:?}; restart it with this KeyHog build",
recovery.recovery_backend
)
})?;
let receipt = keyhog_scanner::BackendRecoveryReceipt::new(
failed_backend,
recovery_backend,
recovery
.recovered_ranges
.into_iter()
.map(|range| {
keyhog_scanner::RecoveredInputRange::new(
range.chunk_index,
range.byte_start,
range.byte_end,
)
})
.collect(),
recovery.reason,
);
if receipt.recovered_chunks() != recovery.recovered_chunks
|| receipt.recovered_bytes() != recovery.recovered_bytes
{
bail!(
"daemon returned inconsistent backend-recovery totals; restart it with this KeyHog build"
);
}
crate::orchestrator::record_completed_backend_recovery(&receipt);
}
Ok((matches, source_coverage_gaps))
}
Response::Error { message } => bail!("daemon: {message}"),
other => bail!("daemon route: expected ScanResults, got {other:?}"),
}
}
#[cfg(unix)]
fn finalize_for_report(matches: Vec<RawMatch>, args: &ScanArgs) -> Result<Vec<VerifiedFinding>> {
let fixtures = if args.no_suppress_test_fixtures {
crate::test_fixture_suppressions::TestFixtureSuppressions::empty()
} else {
crate::test_fixture_suppressions::TestFixtureSuppressions::bundled()
};
let allowlist = load_daemon_allowlist(args)?;
let mut matches: Vec<RawMatch> = matches
.into_iter()
.filter(|m| {
if crate::orchestrator::suppresses_test_fixture(&fixtures, m) {
return false;
}
if crate::orchestrator::suppresses_allowlist_match(&allowlist, m) {
return false;
}
true
})
.collect();
matches = keyhog_scanner::resolution::try_resolve_matches(matches)
.map_err(anyhow::Error::msg)
.context("failed to resolve matches; fix the detector definitions")?;
let filesystem_source = std::sync::Arc::<str>::from("filesystem");
for m in &mut matches {
if m.location.file_path.is_some() && m.location.source.as_ref() != "filesystem" {
m.location.source = filesystem_source.clone();
}
}
let matches = crate::inline_suppression::filter_inline_suppressions(matches);
let scope = args.dedup.to_core();
let deduped = crate::orchestrator::dedup_for_report(matches, &scope);
let findings = crate::orchestrator::skipped_findings_from_deduped(deduped, args.show_secrets);
let rule_suppressor = load_daemon_rule_suppressor(args)?;
Ok(findings
.into_iter()
.filter(|f| !rule_suppressor.matches(f))
.collect())
}
#[cfg(unix)]
fn daemon_allowlist_root(args: &ScanArgs) -> PathBuf {
let Some(path) = args
.path
.as_deref()
.or_else(|| args.input.first().map(PathBuf::as_path))
else {
return PathBuf::from(".");
};
if path.is_dir() {
return path.to_path_buf();
}
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(".")) }
#[cfg(unix)]
fn load_daemon_allowlist(args: &ScanArgs) -> Result<keyhog_core::Allowlist> {
let ignore_path = daemon_allowlist_root(args).join(".keyhogignore");
if ignore_path.exists() {
keyhog_core::Allowlist::load_with_metadata_policy(
&ignore_path,
false,
false,
None,
)
.with_context(|| {
format!(
"daemon route: failed to load {}. Fix or remove the allowlist; refusing to scan with silently ignored policy.",
ignore_path.display()
)
})
} else {
Ok(keyhog_core::Allowlist::default())
}
}
#[cfg(unix)]
fn load_daemon_rule_suppressor(args: &ScanArgs) -> Result<RuleSuppressor> {
let toml_path = daemon_allowlist_root(args).join(".keyhogignore.toml");
if !toml_path.exists() {
return Ok(RuleSuppressor::default());
}
let raw = std::fs::read_to_string(&toml_path).with_context(|| {
format!(
"daemon route: failed to read {}. Fix file permissions or remove the file; refusing \
to scan with silently ignored suppression rules.",
toml_path.display()
)
})?;
match raw.parse::<RuleSuppressor>() {
Ok(s) => Ok(s),
Err(e) => anyhow::bail!(
"daemon route: failed to load {}: {e}. Fix the TOML schema \
(see docs/src/reference/keyhogignore-toml.md) or remove the file; refusing to scan \
with silently ignored suppression rules.",
toml_path.display()
),
}
}