openlatch-client 0.6.1

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! What happens to the relay's CA over its life (I-2 plan 04): the reason strings the daemon's
//! reactions put on a verdict, and the guarded rotation `doctor --fix` runs (D-31).

use crate::model_relay::preflight::{CA_EXPIRING_PREFIX, CA_REASON_MARKER};

/// The reason prefix for a host the daemon released after `RefusalLedger` tripped it (D-32).
pub const REFUSED_PREFIX: &str = "refused by the agent on "; // + host

pub fn refused_reason(host: &str) -> String {
    format!("{CA_REASON_MARKER}{REFUSED_PREFIX}{host}")
}

pub fn expiring_reason(not_after: time::OffsetDateTime) -> String {
    format!("{CA_REASON_MARKER}{CA_EXPIRING_PREFIX}{}", not_after.date())
}

/// `$OPENLATCH_DIR/model-relay/ca.prev` — a rotation's stuck old root, kept for retry. Derived
/// from [`crate::model_relay::ca::ca_dir`] (the sibling directory `ca/` itself lives beside) so
/// the layout lives in one place (S7); same path as before.
pub fn prev_dir(openlatch_dir: &std::path::Path) -> std::path::PathBuf {
    crate::model_relay::ca::ca_dir(openlatch_dir).with_file_name("ca.prev")
}

/// `$OPENLATCH_DIR/model-relay/ca.next` — a rotation's staging directory. Same derivation as
/// [`prev_dir`].
pub fn staging_dir(openlatch_dir: &std::path::Path) -> std::path::PathBuf {
    crate::model_relay::ca::ca_dir(openlatch_dir).with_file_name("ca.next")
}

/// Whole days left on `info`'s validity, from `now`. Negative once expired.
pub fn days_left(info: &crate::model_relay::ca::CaInfo, now: time::OffsetDateTime) -> i64 {
    (info.not_after - now).whole_days()
}

/// What one `rotate` run did.
#[derive(Debug, PartialEq, Eq)]
pub enum RotateOutcome {
    /// Outside the warning window; nothing done.
    NotDue,
    /// Minted, installed, proven and promoted; the old root removed and read back to zero.
    Rotated { old_sha: String, new_sha: String },
    /// The store declined the new CA (verbatim `InstallOutcome::Refused`/`NoGuiSession` text).
    InstallDeclined(String),
    /// The in-process proof failed; the old CA is untouched and still the live one.
    ProofFailed(String),
    /// Step 5: promoted and proven, but the OLD root's removal from the store could not be
    /// proven (S1) — `ca.prev/` is kept so the next run's step 0 retries it, reported here so
    /// the caller never has to re-derive it by comparing a before/after `ca::inspect`.
    PromotedButStuck {
        old_sha: String,
        new_sha: String,
        why: String,
    },
    /// An I/O step failed. The old CA is still the live one.
    Failed(String),
}

/// D-31, in its decided order: mint → install → prove → promote → remove old. The old CA stays
/// the live one until the last step, so every early exit leaves the host exactly as it was
/// except for cleanup of what THIS run added. Runs with the daemon stopped (`doctor --fix` stops
/// a running daemon before any heal step), so no relay reads the directory mid-promotion.
pub fn rotate(
    openlatch_dir: &std::path::Path,
    store: &dyn crate::model_relay::trust_store::TrustStore,
    prove: &dyn Fn(&std::path::Path) -> Result<(), String>, // staging dir → proof
    now: time::OffsetDateTime,
) -> RotateOutcome {
    use crate::model_relay::{ca, trust_store::InstallOutcome};

    let live = ca::ca_dir(openlatch_dir);
    let Some(old) = ca::inspect(&live) else {
        return RotateOutcome::Failed("no CA on disk".into());
    };

    // 0. BEFORE the NotDue check: a previous run that promoted but could not untrust its old
    //    root left ca.prev/ behind, and the live CA is then the NEW one, far outside the window.
    if let Err(why) = retire_prev(openlatch_dir, store) {
        return RotateOutcome::Failed(why);
    }
    if days_left(&old, now) > ca::CA_WARN_DAYS {
        return RotateOutcome::NotDue;
    }

    let staging = staging_dir(openlatch_dir);
    // A crashed earlier run (or this very function's own step 3, C1) can leave a TRUSTED root
    // staged at ca.next/. Its directory is the only record of that root's fingerprint, so it is
    // retired from the store, read back to zero, and only THEN deleted — otherwise a "leftover"
    // wipe would strand a still-trusted root nobody can name any more.
    if let Some(leftover) = ca::inspect(&staging) {
        let retired = store.remove(&leftover.sha256_hex).is_ok()
            && matches!(store.is_trusted(&leftover.sha256_hex), Ok(false));
        if !retired {
            return RotateOutcome::Failed(format!(
                "a stale staged certificate authority (SHA-256 {}) is still trusted in your user \
                 store; its removal was refused — run `openlatch doctor --fix` again",
                leftover.sha256_hex
            ));
        }
    }
    let _ = ca::remove(&staging); // the leftover above is retired, or there was none
    let new = match ca::LocalCa::generate_into(&staging) {
        // 1. mint
        Ok(ca) => ca,
        Err(e) => return RotateOutcome::Failed(e.message),
    };
    let new_sha = new.sha256_hex();
    match store.install(&ca::ca_pem_path(&staging), &new_sha) {
        // 2. install (one prompt)
        InstallOutcome::Installed | InstallOutcome::AlreadyTrusted => {}
        InstallOutcome::NoGuiSession => {
            let _ = ca::remove(&staging);
            return RotateOutcome::InstallDeclined(
                "no desktop session to confirm the install".into(),
            );
        }
        InstallOutcome::Refused(why) => {
            let _ = ca::remove(&staging);
            return RotateOutcome::InstallDeclined(why);
        }
    }
    if let Err(why) = prove(&staging) {
        // 3. prove. `ca.next/` is the only record of the new root's fingerprint, so it is only
        // deleted once the store no longer holds it (C1) — a store that refuses the removal
        // keeps the staged CA on disk, still trusted, for the next run's leftover check above to
        // retry; a silent wipe here would stand a trusted root nobody can name any more.
        let retired =
            store.remove(&new_sha).is_ok() && matches!(store.is_trusted(&new_sha), Ok(false));
        if retired {
            let _ = ca::remove(&staging);
            return RotateOutcome::ProofFailed(why);
        }
        return RotateOutcome::ProofFailed(format!(
            "{why}; the new certificate authority (SHA-256 {new_sha}) is still trusted in your \
             user store — its removal was refused, so ca.next/ was kept for retry"
        ));
    }

    // 4. PROMOTE BEFORE removing the old root (adversarial review, 2026-09-23): both roots are
    //    trusted from here until step 5 completes, so no crash can leave the on-disk CA
    //    untrusted. Two DIRECTORY renames inside $OPENLATCH_DIR/model-relay (one filesystem;
    //    each atomic): ca/ → ca.prev/, then ca.next/ → ca/. A crash between them leaves no ca/
    //    and a loadable ca.next/ — plan 01's `load_or_generate` step 0 finishes the promotion on
    //    the next load.
    let prev = prev_dir(openlatch_dir); // …/model-relay/ca.prev
    let _ = ca::remove(&prev); // step 0 already emptied it
    if let Err(e) = std::fs::rename(&live, &prev) {
        return RotateOutcome::Failed(e.to_string());
    }
    if let Err(e) = std::fs::rename(&staging, &live) {
        let _ = std::fs::rename(&prev, &live); // put the old CA back
        return RotateOutcome::Failed(e.to_string());
    }

    // 5. …then remove the old root and read it back to zero (D-06r). A failure here leaves the
    //    rotation DONE with the old root still trusted; ca.prev/ is kept so the next run's step 0
    //    retries the removal. Reported, never silent.
    let old_gone =
        store.remove(&old.sha256_hex).is_ok() && !store.is_trusted(&old.sha256_hex).unwrap_or(true);
    if !old_gone {
        let why = format!(
            "rotated to {new_sha}, but the previous certificate authority is still trusted; run `openlatch doctor --fix` again"
        );
        return RotateOutcome::PromotedButStuck {
            old_sha: old.sha256_hex,
            new_sha,
            why,
        };
    }
    let _ = ca::remove(&prev);
    RotateOutcome::Rotated {
        old_sha: old.sha256_hex,
        new_sha,
    }
}

/// Rotation step 0, also `heal_relay_ca_with`'s owner carve-out (§2.2): retry removing a stuck
/// `ca.prev/` root from the store (read back), and only then delete `ca.prev/`. No `ca.prev/` →
/// `Ok(())`.
pub fn retire_prev(
    openlatch_dir: &std::path::Path,
    store: &dyn crate::model_relay::trust_store::TrustStore,
) -> Result<(), String> {
    use crate::model_relay::ca;
    let Some(prev_info) = ca::inspect(&prev_dir(openlatch_dir)) else {
        return Ok(());
    };
    let _ = store.remove(&prev_info.sha256_hex);
    if store.is_trusted(&prev_info.sha256_hex).unwrap_or(true) {
        return Err(
            "a previous certificate authority is still trusted; its removal was refused".into(),
        );
    }
    let _ = ca::remove(&prev_dir(openlatch_dir));
    Ok(())
}

/// The production `prove`: an in-process relay on 127.0.0.1:0 serving the STAGING CA, then the
/// two halves of D-09r — `probe_intercept` against the staged `ca.pem`, and the store's
/// `verify_leaf` on a leaf minted from it — for the first intercept host of every ProxyEnv
/// binding. The caller builds `state` (production: `ModelRelayState::new_with_egress` from the
/// loaded config; tests: with 01's resolve override) so the upstream is injectable.
///
/// Binds with `TcpListener::bind` directly rather than [`crate::model_relay::serve_ephemeral`]:
/// the latter `unwrap()`s on a bind failure, and this is library code
/// (`.claude/rules/error-handling.md`) that must return an error instead.
pub async fn prove_with_ephemeral_relay(
    cfg: &crate::config::Config,
    state: crate::model_relay::ModelRelayState,
    staging: &std::path::Path,
    targets: &[(crate::model_relay::wire_format::WireFormat, &'static str)],
    store: &dyn crate::model_relay::trust_store::TrustStore,
) -> Result<(), String> {
    use crate::model_relay::{ca, preflight};

    let hosts: Vec<&'static str> = targets.iter().map(|(_, h)| *h).collect();
    let interceptor = std::sync::Arc::new(
        ca::Interceptor::new(staging, hosts.iter().copied()).map_err(|e| e.message)?,
    );
    if let Ok(mut slot) = state.intercept.write() {
        *slot = Some(interceptor.clone());
    }
    let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
        .await
        .map_err(|e| e.to_string())?;
    let port = listener.local_addr().map_err(|e| e.to_string())?.port();
    let app = crate::model_relay::router(std::sync::Arc::new(state));
    let server = tokio::spawn(async move {
        let _ = axum::serve(listener, app).await;
    });

    let mut result = Ok(());
    for (fmt, host) in targets {
        let upstream = format!("https://{host}");
        result = preflight::probe_intercept(
            cfg,
            port,
            *fmt,
            &upstream,
            &ca::ca_pem_path(staging),
            host,
            preflight::PREFLIGHT_TIMEOUT,
        )
        .await;
        if result.is_ok() {
            result = preflight::prove_store(store, &interceptor, host); // 02's store half (D-09r)
        }
        if result.is_err() {
            break;
        }
    }
    server.abort();
    result
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model_relay::trust_store::test_support::FakeStore;

    // -----------------------------------------------------------------------
    // C1 (adversarial review): a directory `rotate` is about to wipe is the
    // ONLY on-disk record of a fingerprint the store may still trust — a fake
    // configured to refuse removal proves the wipe never happens ahead of a
    // proven read-back to `Ok(false)`.
    // -----------------------------------------------------------------------

    /// C1(a): step 3's proof failure used to unconditionally delete `ca.next/` after firing off
    /// `store.remove(&new_sha)`, ignoring whether the store actually forgot it. The `prove`
    /// closure here learns the freshly-minted root's fingerprint (the only place that can, since
    /// it is minted fresh on every call) and pre-arms the store to refuse removing exactly that
    /// SHA before failing, so a real refusal is exercised rather than a hypothetical one.
    #[test]
    fn a_refused_new_root_removal_keeps_ca_next_and_says_so() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let old = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
            tmp.path(),
        ))
        .expect("generate old");
        let store = FakeStore::default();
        store.trust(&old.sha256_hex());
        let now = old.not_after() - time::Duration::days(20); // inside CA_WARN_DAYS

        let outcome = rotate(
            tmp.path(),
            &store,
            &|staging| {
                let new = crate::model_relay::ca::inspect(staging).expect("staged CA readable");
                store.refuse_removal_of(&new.sha256_hex);
                Err("forced proof failure (fixture)".to_string())
            },
            now,
        );

        match &outcome {
            RotateOutcome::ProofFailed(why) => {
                assert!(why.contains("forced proof failure"), "{why}");
                assert!(
                    why.to_lowercase().contains("trusted"),
                    "must note the new root is still trusted: {why}"
                );
            }
            other => panic!("expected ProofFailed, got {other:?}"),
        }
        assert!(
            staging_dir(tmp.path()).exists(),
            "ca.next must be KEPT — its removal from the store was refused"
        );
        let live = crate::model_relay::ca::inspect(&crate::model_relay::ca::ca_dir(tmp.path()))
            .expect("ca.pem must exist");
        assert_eq!(
            live.sha256_hex,
            old.sha256_hex(),
            "the old CA is still live"
        );
    }

    /// C1(b): the leftover-`ca.next/`-at-start check used to `ca::remove` unconditionally,
    /// exactly like `retire_prev` used to for `ca.prev/`. A leftover the store still trusts must
    /// survive, not be silently wiped.
    #[test]
    fn a_trusted_leftover_ca_next_is_kept_when_its_removal_is_refused() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let old = crate::model_relay::ca::LocalCa::generate_into(&crate::model_relay::ca::ca_dir(
            tmp.path(),
        ))
        .expect("generate old");
        let leftover = crate::model_relay::ca::LocalCa::generate_into(&staging_dir(tmp.path()))
            .expect("generate leftover ca.next");
        let store = FakeStore::default();
        store.trust(&old.sha256_hex());
        store.trust(&leftover.sha256_hex());
        store.refuse_removal_of(&leftover.sha256_hex());
        let now = old.not_after() - time::Duration::days(20);

        let outcome = rotate(
            tmp.path(),
            &store,
            &|_| panic!("must never reach proof — the leftover check must fail first"),
            now,
        );

        match &outcome {
            RotateOutcome::Failed(why) => {
                assert!(why.to_lowercase().contains("trusted"), "{why}");
            }
            other => panic!("expected Failed, got {other:?}"),
        }
        assert!(
            staging_dir(tmp.path()).exists(),
            "the leftover ca.next must be KEPT, not silently wiped"
        );
        assert!(
            store.trusted_directly(&leftover.sha256_hex()),
            "still trusted, per the fixture"
        );
        let live = crate::model_relay::ca::inspect(&crate::model_relay::ca::ca_dir(tmp.path()))
            .expect("ca.pem must exist");
        assert_eq!(
            live.sha256_hex,
            old.sha256_hex(),
            "the live CA is untouched"
        );
    }
}