keyhog 0.5.38

keyhog: detects leaked credentials in source trees, git history, and cloud storage
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
use crate::args::ScanArgs;
use anyhow::{Context, Result};
use keyhog_core::{load_detectors, DetectorSpec};
use keyhog_scanner::ScannerConfig;
use std::path::{Path, PathBuf};

/// Hard ceiling on the parallel thread count. Above this, thread
/// creation overhead + scheduler contention dominates any throughput
/// gain on CPU-bound work. Matches the cap the rayon docs recommend
/// for general-purpose pools and protects against `--threads 9999999`
/// misconfiguration that would either OOM-on-spawn or thrash the
/// scheduler on a 4-core box.
/// Hard ceiling on the worker thread count. Requested values above this are
/// clamped (and logged) by [`sanitise_thread_count`]; spawning thousands of
/// threads thrashes the OS scheduler without speeding the scan up.
pub const MAX_THREADS_CAP: usize = 256;

/// Canonical default for `--ml-threshold` (`ScanArgs::ml_threshold`). Single
/// source of truth for the flag's declared default: the clap `default_value`
/// literal in `args/scan.rs` stringifies this same value, and
/// [`build_scanner_config`] compares the parsed flag against it to decide
/// whether the operator actually moved the knob. An unset flag (value equal to
/// this default) must leave the canonical confidence floor untouched, so the
/// fix for a dead `--ml-threshold` does not silently change default behaviour.
pub const ML_THRESHOLD_DEFAULT: f64 = 0.5;

pub(crate) fn configure_threads(threads: Option<usize>, physical_cores: usize) {
    // Resolution order: --threads CLI arg > KEYHOG_THREADS env > physical core
    // count. Physical (not logical) is the right default for CPU-bound regex
    // - SMT/Hyperthreading siblings share execution units, so 2× the threads
    // yields ~1.1× the throughput while doubling cache pressure.
    //
    // Each source is sanitised through `sanitise_thread_count`, which:
    //   * rejects 0 (rayon would silently use its own default - confusing)
    //   * caps at `MAX_THREADS_CAP` (avoids spawn failures + scheduler thrash)
    // Both paths log a warning so an operator who fat-fingered the value
    // sees what was actually used.
    let (n, source) = if let Some(t) = threads {
        (
            sanitise_thread_count(t, physical_cores, "cli-arg"),
            "cli-arg",
        )
    } else if let Ok(env) = std::env::var("KEYHOG_THREADS") {
        match env.parse::<usize>() {
            Ok(t) => (
                sanitise_thread_count(t, physical_cores, "env:KEYHOG_THREADS"),
                "env:KEYHOG_THREADS",
            ),
            Err(_) => {
                tracing::warn!(value = %env, "ignoring invalid KEYHOG_THREADS value");
                (physical_cores.max(1), "physical-cores")
            }
        }
    } else {
        (physical_cores.max(1), "physical-cores")
    };

    let builder = rayon::ThreadPoolBuilder::new()
        .num_threads(n)
        .stack_size(8 * 1024 * 1024)
        // Cross-OS thread name so external profilers (perf, dtrace,
        // Activity Monitor, htop) can group keyhog workers separately
        // from the calling process. Previously macOS-only.
        .thread_name(|i| format!("keyhog-worker-{i}"));

    if let Err(error) = builder.build_global() {
        tracing::warn!(
            requested_threads = n,
            source,
            "failed to configure rayon thread pool: {error}"
        );
    } else {
        tracing::info!(
            threads = n,
            source,
            physical_cores,
            "rayon thread pool configured"
        );
    }
}

/// Clamp a user-supplied thread count to a sane range. Logs a
/// warning when the value was outside the accepted bounds so an
/// operator who passed `--threads 0` or `--threads 999999` sees what
/// the scanner actually used.
fn sanitise_thread_count(requested: usize, physical_cores: usize, source: &'static str) -> usize {
    let safe_default = physical_cores.max(1);
    if requested == 0 {
        tracing::warn!(
            source,
            requested = 0,
            using = safe_default,
            "thread count of 0 is not meaningful; falling back to physical-cores"
        );
        return safe_default;
    }
    if requested > MAX_THREADS_CAP {
        tracing::warn!(
            source,
            requested,
            cap = MAX_THREADS_CAP,
            "requested thread count exceeds cap; clamping"
        );
        return MAX_THREADS_CAP;
    }
    requested
}

#[doc(hidden)]
#[allow(dead_code)]
pub fn sanitise_thread_count_for_test(
    requested: usize,
    physical_cores: usize,
    source: &'static str,
) -> usize {
    sanitise_thread_count(requested, physical_cores, source)
}

pub(crate) fn auto_discover_detectors(path: &Path) -> Result<PathBuf> {
    if let Ok(env_path) = std::env::var("KEYHOG_DETECTORS") {
        let p = PathBuf::from(&env_path);
        if p.exists() && p.is_dir() {
            return Ok(p);
        }
    }

    if path == Path::new("detectors") && !path.exists() {
        let mut default_dirs: Vec<Option<PathBuf>> = vec![
            dirs::home_dir().map(|h| h.join(".keyhog/detectors")),
            dirs::data_dir().map(|d| d.join("keyhog/detectors")),
            dirs::data_local_dir().map(|d| d.join("keyhog/detectors")),
        ];
        if cfg!(unix) {
            default_dirs.push(Some(PathBuf::from("/usr/share/keyhog/detectors")));
            default_dirs.push(Some(PathBuf::from("/usr/local/share/keyhog/detectors")));
        }
        default_dirs.push(
            std::env::current_exe()
                .ok()
                .and_then(|p| p.parent().map(|p| p.join("detectors"))),
        );
        for dir in default_dirs.into_iter().flatten() {
            if dir.exists() && dir.is_dir() {
                tracing::info!(detectors_dir = %dir.display(), "auto-detected detectors directory");
                return Ok(dir);
            }
        }
    }
    Ok(path.to_path_buf())
}

pub(crate) fn load_detectors_with_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
    validate_detector_path_for_scan(path)?;
    if path.exists() && path.is_dir() {
        // The parse cache lives in the user's XDG cache dir, NOT inside the
        // detectors directory. A system install puts detectors under a
        // root-owned, read-only tree (e.g. /opt/keyhog/detectors,
        // /usr/share/keyhog/detectors); writing `.keyhog-cache.json` there
        // failed with `Permission denied` on EVERY run, spamming two WARN
        // lines and silently re-parsing each time. Keying the cache by the
        // source dir keeps distinct detector directories from colliding;
        // `load_detector_cache` still mtime-checks the source TOMLs for
        // staleness regardless of where the cache file lives.
        let cache_path = detector_cache_path(path);
        if let Some(cache_path) = &cache_path {
            if let Some(cached) = keyhog_core::load_detector_cache(cache_path, path) {
                require_non_empty_detectors(&cached, path)?;
                return Ok(cached);
            }
        }
        let loaded = load_detectors(path)?;
        require_non_empty_detectors(&loaded, path)?;
        if let Some(cache_path) = &cache_path {
            if let Err(error) = keyhog_core::save_detector_cache(&loaded, cache_path) {
                // A read-only or absent cache dir is not an error the operator
                // can act on and not worth a per-run WARN: the scan proceeds,
                // just without the (small) parse-cache speedup. Debug-level so
                // `-v` still surfaces it for diagnosis.
                tracing::debug!(
                    cache_path = %cache_path.display(),
                    %error,
                    "detector parse cache not written (cache dir unwritable); \
                     re-parsing TOML each run"
                );
            }
        }
        return Ok(loaded);
    }
    load_detectors_embedded_or_fail(path)
}

/// Path to the detector parse cache in the user's XDG cache dir, keyed by the
/// source directory so multiple `--detectors` trees don't collide. Returns
/// `None` when no cache dir is resolvable (cache simply disabled). The
/// `.json` is created on first successful parse and revalidated against the
/// source TOMLs' mtimes by `keyhog_core::load_detector_cache`.
fn detector_cache_path(source_dir: &Path) -> Option<std::path::PathBuf> {
    use std::hash::{Hash, Hasher};
    let canonical = std::fs::canonicalize(source_dir).unwrap_or_else(|_| source_dir.to_path_buf());
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    canonical.hash(&mut hasher);
    // Version-scope the key: the embedded/default corpus shape can change
    // across keyhog versions, so a stale cache from an old binary is never
    // reused by a new one.
    env!("CARGO_PKG_VERSION").hash(&mut hasher);
    let key = hasher.finish();
    Some(
        dirs::cache_dir()?
            .join("keyhog")
            .join(format!("detectors-{key:016x}.json")),
    )
}

/// Load detectors without writing or reading the on-disk
/// `.keyhog-cache.json`. Used by `--lockdown` to avoid touching disk.
/// Falls through to the embedded TOML corpus when no detectors dir
/// exists, matching `load_detectors_with_cache`'s behaviour.
pub(crate) fn load_detectors_no_cache(path: &Path) -> Result<Vec<DetectorSpec>> {
    validate_detector_path_for_scan(path)?;
    if path.exists() && path.is_dir() {
        let loaded = load_detectors(path).map_err(anyhow::Error::from)?;
        require_non_empty_detectors(&loaded, path)?;
        return Ok(loaded);
    }
    load_detectors_embedded_or_fail(path)
}

/// Hard-fail when detector loading produces zero specs. The
/// `load_detectors` path returns `Ok(Vec::new())` for an empty
/// directory, a directory full of malformed TOMLs that all get
/// quality-gate rejected, or a typo'd `--detectors` path that
/// happens to be a directory. Without this gate the scan runs
/// against zero patterns, finds nothing, and exits SUCCESS - the
/// user (or their CI) reads "no findings" and assumes the code
/// is clean. That's the definition of a silent-data-loss bug.
///
/// `pub(crate)` so subcommands (`watch`, `scan-system`, `explain`)
/// share the gate. They all have their own `load_detectors`
/// helpers that historically bypassed this check.
pub(crate) fn require_non_empty_detectors(
    detectors: &[DetectorSpec],
    detectors_path: &Path,
) -> Result<()> {
    if detectors.is_empty() {
        anyhow::bail!(
            "loaded zero detectors from {}. \
             Fix: verify the directory contains valid `*.toml` detector \
             specs (run `keyhog detectors --detectors {}` to see \
             which TOMLs were rejected, if any). Refusing to scan with \
             no detectors loaded - that would silently report `no \
             findings` regardless of what's in the source.",
            detectors_path.display(),
            detectors_path.display(),
        );
    }
    Ok(())
}

/// Load detectors from a directory, falling back to the embedded TOML
/// corpus when the directory is empty / non-existent / all-rejected.
///
/// `pub(crate)` so the per-subcommand modules (`watch`, `explain`,
/// `scan_system`, `detectors`) can each call this one helper instead of
/// shipping divergent copies. Pre-2026-05-24 each subcommand had its
/// own load+fallback wrapper and the copies had drifted on error
/// messages and on the fallback-to-embedded branch - kimi-dedup rows #4-6.
// `pub` (was pub(crate)) so the relocated explain test loads the embedded
// corpus through the same path production uses (no_inline_tests_in_src gate).
pub fn load_detectors_or_embedded(path: &Path) -> Result<Vec<DetectorSpec>> {
    validate_detector_path_for_scan(path)?;
    if path.exists() && path.is_dir() {
        let loaded = load_detectors(path).context("loading detectors from directory")?;
        require_non_empty_detectors(&loaded, path)?;
        return Ok(loaded);
    }
    load_detectors_embedded_or_fail(path)
}

fn validate_detector_path_for_scan(path: &Path) -> Result<()> {
    if path.exists() && !path.is_dir() {
        anyhow::bail!(
            "detectors path '{}' is not a directory. \
             Fix: pass a directory containing detector TOML files, or omit \
             --detectors to use the embedded corpus.",
            path.display()
        );
    }
    if !path.exists() && path != Path::new("detectors") {
        anyhow::bail!(
            "detectors directory '{}' does not exist. \
             Fix: pass an existing detector directory, or omit --detectors to \
             use the embedded corpus.",
            path.display()
        );
    }
    Ok(())
}

fn load_detectors_embedded_or_fail(path: &Path) -> Result<Vec<DetectorSpec>> {
    let embedded = keyhog_core::embedded_detector_tomls();
    if !embedded.is_empty() {
        tracing::info!(
            embedded_count = embedded.len(),
            "using embedded detectors (no external detectors directory found)"
        );
        let mut detectors = Vec::new();
        for (name, toml_content) in embedded {
            match toml::from_str::<keyhog_core::DetectorFile>(toml_content) {
                Ok(file) => detectors.push(file.detector),
                Err(error) => {
                    tracing::debug!("failed to parse embedded detector {}: {}", name, error)
                }
            }
        }
        if detectors.is_empty() {
            anyhow::bail!(
                "no detectors loaded from embedded data - every embedded TOML \
                 failed to parse. Fix: pass `--detectors <DIR>` to load from a \
                 directory of TOMLs, or rebuild keyhog from source so the \
                 build.rs detector-embedding step re-runs."
            );
        }
        return Ok(detectors);
    }

    anyhow::bail!(
        "detectors directory '{}' not found and no embedded detectors available. \
         Fix: specify --detectors <path> or set KEYHOG_DETECTORS env var",
        path.display()
    )
}

pub fn build_scanner_config(args: &ScanArgs) -> ScannerConfig {
    // The preset (`--fast` / `--deep`) is a BASE, not a terminal state. It
    // seeds decode-depth / entropy / ml defaults; the per-flag overrides below
    // then layer on top. Pre-fix this function early-returned at the preset, so
    // `--deep --min-confidence 0.9` (or `--deep --entropy-threshold 5.0`, or any
    // `--known-prefixes` / keyword list) silently dropped the explicit override
    // - a coherence leak where "what the operator asked for" != "what ran". Only
    // `--no-decode` / `--no-entropy` are clap-conflicting with the presets
    // (`conflicts_with_all` on the `fast`/`deep` flags), so every other override
    // is a legitimate refinement of the preset base and must take effect.
    let mut config = if args.precision {
        ScannerConfig::high_precision()
    } else if args.fast {
        ScannerConfig::fast()
    } else if args.deep {
        ScannerConfig::thorough()
    } else {
        ScannerConfig::default()
    };

    if let Some(depth) = args.decode_depth {
        config.max_decode_depth = depth;
    }
    if args.no_decode {
        config.max_decode_depth = 0;
    }
    if let Some(size) = args.decode_size_limit {
        config.max_decode_bytes = size;
    }
    if let Some(conf) = args.min_confidence {
        // Under `--precision` the 0.85 floor is a MINIMUM the operator may
        // raise but not lower: `--precision --min-confidence 0.9` tightens to
        // 0.9, while `--precision --min-confidence 0.3` stays at 0.85 (the
        // documented "`--min-confidence` still overrides the floor on top"
        // contract is one-directional - it cannot punch a hole in the precision
        // bar). Every other mode lets the operator set the floor outright.
        config.min_confidence = if args.precision {
            conf.max(ScannerConfig::HIGH_PRECISION_MIN_CONFIDENCE)
        } else {
            conf
        };
    }
    // `--ml-threshold` is the documented "minimum ML confidence score for
    // generic entropy secrets" knob. Pre-fix it was parsed + range-validated
    // but never read by any non-test path, so `--ml-threshold 0.9` silently did
    // nothing (M21: a dead precision lever giving false confidence). Wire it as
    // a confidence FLOOR composed with `.max()` - mirroring the precision-mode
    // composition just above and the "minimum score" wording of the flag - so a
    // raised threshold tightens the bar a generic/entropy finding must clear,
    // while a lowered one can never punch below an operator's `--min-confidence`
    // (or the precision floor). Gated on a real move off the declared default
    // (`ML_THRESHOLD_DEFAULT`): an unset flag leaves the canonical 0.40 floor
    // untouched, so behaviour off the bug path is unchanged.
    if args.ml_threshold != ML_THRESHOLD_DEFAULT {
        config.min_confidence = config.min_confidence.max(args.ml_threshold);
    }
    // Keep the fixture opt-out coherent: skip both value suppressions and the
    // test/example path confidence penalty.
    config.penalize_test_paths = !args.no_suppress_test_fixtures;

    // `--no-entropy` conflicts with the presets at the clap layer, so under a
    // preset this is always `true` (entropy stays whatever the preset set). For
    // the no-preset path it honours the flag. Likewise `--no-decode` is preset-
    // conflicting; decode-depth above still applies for the no-preset path.
    if !(args.fast || args.deep || args.precision) {
        config.entropy_enabled = !args.no_entropy;
    }
    if let Some(threshold) = args.entropy_threshold {
        config.entropy_threshold = threshold;
    }
    config.entropy_in_source_files = args.entropy_source_files;
    // Entropy candidates are scored through the MoE (model authoritative) by
    // default; `--no-entropy-ml-scoring` restores the legacy heuristic emit.
    // No-op unless entropy + ML are both on (gated in scan_entropy_fallback).
    config.entropy_ml_authoritative = !args.no_entropy_ml_scoring;
    // Keyword-anchored generic values use the relaxed entropy floor by default
    // (the keyword key is the evidence; precision carried by the MoE);
    // `--no-keyword-low-entropy` restores the high-entropy-only generic gate.
    // No-op unless the generic keyword bridge fires (scan_generic_assignments).
    // Composed with `&&` (not assigned) so the flag is one-directional: it can
    // only DISABLE the relaxed floor, never re-enable it under a preset that
    // turned it off (e.g. `--precision`, whose high_precision() base sets it
    // false). Mirrors the one-directional precision min_confidence contract.
    config.generic_keyword_low_entropy =
        config.generic_keyword_low_entropy && !args.no_keyword_low_entropy;
    config.scan_comments = args.scan_comments;
    config.ml_enabled = !args.fast && !args.no_ml;
    if let Some(weight) = args.ml_weight {
        config.ml_weight = weight;
    }
    config.unicode_normalization = !args.no_unicode_norm;
    if !args.known_prefixes.is_empty() {
        config.known_prefixes = args.known_prefixes.clone();
    }
    if !args.secret_keywords.is_empty() {
        config.secret_keywords = args.secret_keywords.clone();
    }
    if !args.test_keywords.is_empty() {
        config.test_keywords = args.test_keywords.clone();
    }
    if !args.placeholder_keywords.is_empty() {
        config.placeholder_keywords = args.placeholder_keywords.clone();
    }
    // Re-run the NaN/range safety net AFTER every CLI flag and `.keyhog.toml`
    // override has been merged in. `From<ScanConfig>` sanitises once at
    // construction time, but the overrides above (e.g. `config.ml_weight =
    // weight`, `config.entropy_threshold = threshold`) mutate the numeric
    // fields directly afterwards and would otherwise smuggle out-of-range
    // values straight to the engine: `--ml-weight 5.0` / `-1.0` (the ML blend
    // `w*ml + (1-w)*heuristic` in scan_postprocess relies on `w in [0,1]`) and
    // `--entropy-threshold 99` / `-5` (a threshold > 8.0 can never fire,
    // disabling the entropy detector; a negative one makes `entropy >= thr`
    // always true). Neither `--ml-weight` nor `--entropy-threshold` has a
    // clamping clap value_parser, so this is the only place the override layer
    // can honour the same invariant the `From` path enforces. Idempotent.
    config.sanitise();
    config
}

/// The single resolved scan configuration: the END of the precedence chain
/// `compiled-default -> [scan] table -> flat ConfigFile fields -> CLI flags`,
/// already merged into the engine's [`ScannerConfig`] PLUS the post-scan policy
/// the live worker needs (the per-detector confidence floors and the global
/// floor / ml gate read in `orchestrator/postprocess.rs`).
///
/// This exists to kill the "tuned != benched != shipped" leak: before it, the
/// scan-time floor lived in `ScannerConfig.min_confidence` (declared default
/// 0.5) while the post-scan floor was re-derived in `postprocess.rs` from
/// `args.min_confidence.unwrap_or(0.3)` - a SECOND, different literal, gated on
/// `!no_ml`. Two floors meant the value the operator set, the value the engine
/// applied, and the value postprocess applied could all disagree. Resolving
/// once and handing the live worker this struct makes "what runs" a single,
/// printable answer (see `KEYHOG_PRINT_EFFECTIVE_CONFIG=1`).
#[derive(Debug, Clone)]
pub struct ResolvedScanConfig {
    /// Engine-side config consumed by `CompiledScanner::with_config`.
    pub scanner: ScannerConfig,
    /// The global post-scan confidence floor a finding must clear to be
    /// reported. This is `scanner.min_confidence` - the SAME resolved value the
    /// engine uses, never a re-read of the raw args or a second literal. The
    /// live worker reads THIS, not `args.min_confidence.unwrap_or(0.3)`.
    pub min_confidence: f64,
    /// Whether ML confidence scoring is enabled. Mirrors `scanner.ml_enabled`.
    /// The post-scan floor applies regardless of this: disabling ML changes how
    /// confidence is *computed*, not whether a `--min-confidence` floor the
    /// operator set is honoured. (Pre-fix the floor was gated on `!no_ml`, so
    /// `--no-ml` silently bypassed `--min-confidence` entirely.)
    pub ml_enabled: bool,
    /// Per-detector floors from `.keyhog.toml` `[detector.<id>] min_confidence`.
    /// Take precedence over `min_confidence` for the matching detector id.
    pub detector_min_confidence: std::collections::HashMap<String, f64>,
    /// Detector ids disabled via `.keyhog.toml` `[detector.<id>] enabled = false`.
    /// These are dropped from the loaded corpus before scanner compilation.
    pub disabled_detectors: std::collections::HashSet<String>,
    /// Whether `.keyhog.toml` requires lockdown mode for this scan.
    pub require_lockdown: bool,
}

/// Resolve the full scan configuration in one place: run the precedence merge
/// (compiled default -> `[scan]` table -> flat `ConfigFile` fields -> CLI flags)
/// via [`apply_config_file`], build the engine [`ScannerConfig`], and surface
/// the post-scan policy (global floor, ml gate, per-detector floors) so the live
/// worker consumes a resolved struct instead of re-reading raw args + a literal.
///
/// `args` is mutated in place by the config-file merge (CLI flags already win;
/// the merge only fills fields the operator left at their default), exactly as
/// the orchestrator's pre-existing `apply_config_file(&mut args)` call did. The
/// caller keeps the same `args` for the surfaces that still read it directly
/// (severity filter, dedup scope, verify/show-secrets gating).
pub fn resolve_scan_config(args: &mut ScanArgs) -> ResolvedScanConfig {
    let outcome = crate::config::apply_config_file(args);
    let scanner = build_scanner_config(args);
    // The post-scan floor is the SAME value the engine resolved - read it back
    // off the built config rather than re-deriving from `args`, so the two can
    // never drift. `ScannerConfig::from`/`sanitise` already clamped NaN/range.
    let min_confidence = scanner.min_confidence;
    let ml_enabled = scanner.ml_enabled;
    ResolvedScanConfig {
        scanner,
        min_confidence,
        ml_enabled,
        detector_min_confidence: outcome.detector_min_confidence,
        disabled_detectors: outcome.disabled_detectors.into_iter().collect(),
        require_lockdown: outcome.require_lockdown,
    }
}

/// Hidden effective-config surface: the coherence oracle. Returns
/// `true` when the dump was requested (the caller should then print-and-exit
/// SUCCESS without scanning), `false` for a normal scan.
///
/// Triggered today by the `KEYHOG_PRINT_EFFECTIVE_CONFIG=1` env var, matching
/// the existing env-or-flag precedent (`KEYHOG_BACKEND`/`--backend`,
/// `KEYHOG_THREADS`/`--threads`). Writes the rendered block to stdout so it is
/// captured by the same `--output`-less stdout path the formatted report uses.
pub fn print_effective_config_if_requested(resolved: &ResolvedScanConfig) -> bool {
    let requested = std::env::var("KEYHOG_PRINT_EFFECTIVE_CONFIG")
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false);
    if !requested {
        return false;
    }
    print!("{}", render_effective_config(resolved));
    true
}

/// Render the resolved scan config as a stable, human + machine readable block
/// for the hidden env-var coherence oracle. It
/// answers "what will actually run?" in one place: the resolved engine config
/// AND the post-scan floors, so a test (or an operator) can assert that the
/// tuned value, the benched value, and the shipped value are the same number.
///
/// Emitted as deterministic `key = value` lines (sorted detector floors) rather
/// than JSON so it is greppable and diffable in dogfood snapshots without a
/// serde derive on the engine `ScannerConfig` (which lives in another crate).
pub fn render_effective_config(resolved: &ResolvedScanConfig) -> String {
    use std::fmt::Write as _;
    let s = &resolved.scanner;
    let mut out = String::new();
    let _ = writeln!(out, "[effective-config]");
    let _ = writeln!(out, "min_confidence = {}", resolved.min_confidence);
    let _ = writeln!(out, "ml_enabled = {}", resolved.ml_enabled);
    let _ = writeln!(out, "ml_weight = {}", s.ml_weight);
    let _ = writeln!(out, "entropy_enabled = {}", s.entropy_enabled);
    let _ = writeln!(
        out,
        "entropy_ml_authoritative = {}",
        s.entropy_ml_authoritative
    );
    let _ = writeln!(
        out,
        "generic_keyword_low_entropy = {}",
        s.generic_keyword_low_entropy
    );
    let _ = writeln!(out, "entropy_threshold = {}", s.entropy_threshold);
    let _ = writeln!(
        out,
        "entropy_in_source_files = {}",
        s.entropy_in_source_files
    );
    let _ = writeln!(out, "max_decode_depth = {}", s.max_decode_depth);
    let _ = writeln!(out, "max_decode_bytes = {}", s.max_decode_bytes);
    let _ = writeln!(out, "scan_comments = {}", s.scan_comments);
    let _ = writeln!(out, "unicode_normalization = {}", s.unicode_normalization);
    let _ = writeln!(
        out,
        "disabled_detectors = {}",
        resolved.disabled_detectors.len()
    );
    let _ = writeln!(out, "known_prefixes = {}", s.known_prefixes.len());
    let _ = writeln!(out, "secret_keywords = {}", s.secret_keywords.len());
    let _ = writeln!(out, "test_keywords = {}", s.test_keywords.len());
    let _ = writeln!(
        out,
        "placeholder_keywords = {}",
        s.placeholder_keywords.len()
    );
    let mut floors: Vec<(&String, &f64)> = resolved.detector_min_confidence.iter().collect();
    floors.sort_by(|a, b| a.0.cmp(b.0));
    for (id, floor) in floors {
        let _ = writeln!(out, "detector_min_confidence.{id} = {floor}");
    }
    out
}