keyclaw 0.2.1

Local MITM proxy that keeps secrets out of LLM traffic
Documentation
use std::path::PathBuf;
use std::time::Duration;

use crate::config::Config;
use crate::logging::LogLevel;

fn test_config(vault_path: PathBuf) -> Config {
    Config {
        proxy_listen_addr: "127.0.0.1:8877".into(),
        proxy_url: "http://127.0.0.1:8877".into(),
        ca_cert_path: String::new(),
        vault_path,
        vault_passphrase: None,
        fail_closed: true,
        max_body_bytes: 2 * 1024 * 1024,
        detector_timeout: Duration::from_secs(4),
        known_codex_hosts: Vec::new(),
        known_claude_hosts: Vec::new(),
        known_provider_hosts: Vec::new(),
        include_hosts: Vec::new(),
        gitleaks_config_path: None,
        log_level: LogLevel::Info,
        unsafe_log: false,
        require_mitm_effective: true,
        notice_mode: crate::redaction::NoticeMode::Verbose,
        dry_run: false,
        entropy_enabled: true,
        entropy_threshold: 3.5,
        entropy_min_len: 20,
        audit_log_path: Some(crate::audit::default_audit_log_path()),
        allowlist: crate::allowlist::Allowlist::default(),
        config_file_status: crate::config::ConfigFileStatus::Missing(
            crate::config::default_config_path(),
        ),
    }
}

#[test]
fn load_runtime_ruleset_falls_back_to_bundled_rules_when_custom_file_fails() {
    let temp = tempfile::tempdir().expect("tempdir");
    let mut cfg = test_config(temp.path().join("vault.enc"));
    cfg.gitleaks_config_path = Some(temp.path().join("missing-gitleaks.toml"));

    let ruleset = super::detection::load_runtime_ruleset(&cfg).expect("fallback to bundled rules");

    assert!(
        !ruleset.rules.is_empty(),
        "bundled fallback should still load shipped rules"
    );
}

#[test]
fn read_and_validate_proxy_pid_returns_none_for_missing_file() {
    let temp = tempfile::tempdir().expect("tempdir");
    let pid_path = temp.path().join("proxy.pid");
    assert!(super::proxy_daemon::read_and_validate_proxy_pid(&pid_path).is_none());
}

#[test]
fn read_and_validate_proxy_pid_returns_none_for_invalid_pid() {
    let temp = tempfile::tempdir().expect("tempdir");
    let pid_path = temp.path().join("proxy.pid");
    std::fs::write(&pid_path, "not-a-number").expect("write");
    assert!(super::proxy_daemon::read_and_validate_proxy_pid(&pid_path).is_none());
    assert!(!pid_path.exists());
}

#[test]
fn read_and_validate_proxy_pid_returns_none_for_dead_process() {
    let temp = tempfile::tempdir().expect("tempdir");
    let pid_path = temp.path().join("proxy.pid");
    std::fs::write(&pid_path, "4294967").expect("write");
    assert!(super::proxy_daemon::read_and_validate_proxy_pid(&pid_path).is_none());
    assert!(!pid_path.exists());
}

#[test]
fn is_keyclaw_proxy_process_rejects_unrelated_process() {
    let pid = std::process::id();
    assert!(!super::proxy_daemon::is_keyclaw_proxy_process(pid));
}

#[test]
fn read_proxy_addr_from_env_extracts_address() {
    let temp = tempfile::tempdir().expect("tempdir");
    let env_path = temp.path().join("env.sh");
    let content = "# comment\nexport HTTP_PROXY='http://127.0.0.1:9988'\nexport HTTPS_PROXY='http://127.0.0.1:9988'\n";
    std::fs::write(&env_path, content).expect("write");
    assert_eq!(
        super::proxy_daemon::read_proxy_addr_from_env(&env_path),
        Some("127.0.0.1:9988".to_string())
    );
}

#[test]
fn read_proxy_addr_from_env_returns_none_for_missing_file() {
    let temp = tempfile::tempdir().expect("tempdir");
    let env_path = temp.path().join("env.sh");
    assert_eq!(
        super::proxy_daemon::read_proxy_addr_from_env(&env_path),
        None
    );
}

#[test]
fn detached_proxy_env_forwards_include_hosts() {
    let temp = tempfile::tempdir().expect("tempdir");
    let mut cfg = test_config(temp.path().join("vault.enc"));
    cfg.add_include_hosts(vec![
        "*my-custom-api.com*".into(),
        "api.together.xyz".into(),
    ]);

    let env = super::proxy_daemon::detached_proxy_env(&cfg);

    assert_eq!(
        env.iter()
            .find(|(key, _)| key == "KEYCLAW_INCLUDE_HOSTS")
            .map(|(_, value): &(String, String)| value.as_str()),
        Some("*my-custom-api.com*,api.together.xyz")
    );
}