openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! SessionStart event enrichment.
//!
//! The daemon intercepts session_start hook events from Claude Code (via the
//! existing /hooks endpoint), walks the in-memory `ContentHashCache`, and
//! injects `openlatch.declared_*` fields into the `data` payload before
//! forwarding to cloud. The cloud's session_start handler reads these fields,
//! does cache lookup for each declared source, and returns the aggregate
//! verdict (per design §4.10 / Phase-2 §2).
//!
//! Enrichment is best-effort: any failure logs `OL-2007` and the un-enriched
//! envelope continues through the normal pipeline (fail-open).

use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::sync::mpsc;

use super::cache::ContentHashCache;
use super::monitor::ConfigChangeRequest;

/// Bound on the recent-cwd cache. Discover-project-root walks up to 25
/// `stat()` calls per hook event; a process typically sees a handful of
/// distinct cwds across its session, so a tiny ring is enough.
const PROJECT_ROOT_CACHE_CAP: usize = 32;

type ProjectRootCache = Mutex<VecDeque<(String, Option<PathBuf>)>>;

/// (cwd_string, resolved_project_root) pairs in FIFO order. A small
/// `Mutex<VecDeque>` outperforms an `LruCache` crate for this size and
/// keeps the dependency footprint flat.
static PROJECT_ROOT_CACHE: std::sync::LazyLock<ProjectRootCache> =
    std::sync::LazyLock::new(|| Mutex::new(VecDeque::with_capacity(PROJECT_ROOT_CACHE_CAP)));

/// One declared config source attached to a session_start payload. The cloud
/// resolves `(source_id, content_hash)` against its verdict cache.
#[derive(Debug, Clone, Serialize)]
pub struct DeclaredSource {
    /// `{agent}:{kind}:{path_hash}` — opaque to the client.
    pub source_id: String,
    /// Hex SHA-256 of the canonical content (matches `configcontenthash` on
    /// the corresponding `ai.openlatch.config.*` event).
    pub content_hash: String,
    /// Hex SHA-256 of the absolute path (privacy-preserving — never the
    /// path itself).
    pub path_hash: String,
}

/// Errors returned by [`enrich_session_start`]. The daemon logs them under
/// `ERR_INVENTORY_ENRICH_FAILED` and continues fail-open.
#[derive(thiserror::Error, Debug)]
pub enum EnrichError {
    #[error("cwd missing from session_start payload")]
    CwdMissing,
}

/// Enrich a session_start `data` payload in place. Adds
/// `openlatch.declared_mcp_sources`, `openlatch.declared_skills`,
/// `openlatch.declared_rules_hash`, and `openlatch.cwd` (path hash). When
/// `monitor_tx` is provided and a project root is discovered above the
/// cwd, dispatches a `ProjectScopeRegister` so project-scope manifest
/// paths get scanned.
///
/// Operates directly on the envelope's `data: Option<Value>` slot to
/// avoid a `serde_json::to_value` + `from_value` round-trip per session.
pub async fn enrich_session_start(
    data: &mut Option<serde_json::Value>,
    cache: &ContentHashCache,
    monitor_tx: Option<&mpsc::Sender<ConfigChangeRequest>>,
) -> Result<(), EnrichError> {
    let cwd = data
        .as_ref()
        .and_then(|d| d.get("cwd"))
        .and_then(|v| v.as_str())
        .ok_or(EnrichError::CwdMissing)?
        .to_string();

    let _ = try_register_project_from_cwd(&cwd, monitor_tx).await;

    let mut mcp_sources: Vec<DeclaredSource> = Vec::new();
    let mut skill_sources: Vec<DeclaredSource> = Vec::new();
    let mut rules_pairs: Vec<([u8; 32], [u8; 32])> = Vec::new();

    for entry in cache.snapshot() {
        let path_hash_hex = hex::encode(entry.path_hash);
        let source_id = format!("{}:{}:{}", entry.agent, entry.kind, path_hash_hex);
        let declared = DeclaredSource {
            source_id,
            content_hash: hex::encode(entry.content_hash),
            path_hash: path_hash_hex,
        };
        match entry.kind.as_str() {
            "mcp" => mcp_sources.push(declared),
            "skill" => skill_sources.push(declared),
            "rules" => rules_pairs.push((entry.path_hash, entry.content_hash)),
            _ => {}
        }
    }

    rules_pairs.sort_by_key(|a| a.0);
    let mut combined = Sha256::new();
    for (ph, ch) in &rules_pairs {
        combined.update(ph);
        combined.update(ch);
    }
    let rules_hash_hex = hex::encode(combined.finalize());
    let cwd_hash_hex = hex::encode(Sha256::digest(cwd.as_bytes()));

    if let Some(obj) = data.as_mut().and_then(|v| v.as_object_mut()) {
        obj.insert(
            "openlatch.declared_mcp_sources".into(),
            serde_json::to_value(&mcp_sources).unwrap_or_default(),
        );
        obj.insert(
            "openlatch.declared_skills".into(),
            serde_json::to_value(&skill_sources).unwrap_or_default(),
        );
        obj.insert(
            "openlatch.declared_rules_hash".into(),
            serde_json::Value::String(rules_hash_hex),
        );
        obj.insert(
            "openlatch.cwd".into(),
            serde_json::Value::String(cwd_hash_hex),
        );
    }

    let bucket = match mcp_sources.len() + skill_sources.len() + rules_pairs.len() {
        0..=5 => "1-5",
        6..=20 => "6-20",
        21..=50 => "21-50",
        _ => "50+",
    };
    crate::telemetry::capture_global(crate::telemetry::Event::config_session_start_enriched(
        "claude-code",
        bucket,
    ));

    Ok(())
}

/// Walk up from `cwd`, discover a project root, and (if `monitor_tx` is
/// connected) dispatch a `ProjectScopeRegister`. Returns the discovered
/// root (or `None` if no project markers are nearby) so callers that need
/// it for further enrichment can reuse the result.
///
/// The monitor's `registered_projects: HashSet<PathBuf>` already dedupes
/// repeat registrations, so this is safe to call on every hook event that
/// carries a `cwd` — not just SessionStart. That closes the daemon-
/// started-mid-session timing window described in the inventory plan.
pub async fn try_register_project_from_cwd(
    cwd: &str,
    monitor_tx: Option<&mpsc::Sender<ConfigChangeRequest>>,
) -> Option<PathBuf> {
    let project_root = cached_project_root(cwd);
    if let (Some(root), Some(tx)) = (&project_root, monitor_tx) {
        let _ = tx
            .send(ConfigChangeRequest::ProjectScopeRegister {
                project_root: root.clone(),
            })
            .await;
    }
    project_root
}

/// Wrap `discover_project_root` with a process-wide FIFO cache keyed by cwd
/// string. Same-cwd repeats become one `Mutex` lookup; cold misses pay the
/// 25-stat walk once.
fn cached_project_root(cwd: &str) -> Option<PathBuf> {
    if let Ok(cache) = PROJECT_ROOT_CACHE.lock() {
        if let Some((_, hit)) = cache.iter().find(|(k, _)| k == cwd) {
            return hit.clone();
        }
        // Drop the guard before doing the filesystem walk so we don't hold
        // it across syscalls that other callers might be racing for.
        drop(cache);
    }
    let resolved = discover_project_root(Path::new(cwd));
    if let Ok(mut cache) = PROJECT_ROOT_CACHE.lock() {
        if !cache.iter().any(|(k, _)| k == cwd) {
            if cache.len() >= PROJECT_ROOT_CACHE_CAP {
                cache.pop_front();
            }
            cache.push_back((cwd.to_string(), resolved.clone()));
        }
    }
    resolved
}

/// Walk up from `cwd` looking for project markers (`.git`, `.hg`, `.svn`,
/// `.openlatch`, `.mcp.json`). Returns `Some(root)` if found within 5 levels.
pub fn discover_project_root(cwd: &Path) -> Option<PathBuf> {
    const MARKERS: &[&str] = &[".git", ".hg", ".svn", ".openlatch", ".mcp.json"];
    let mut current = cwd.to_path_buf();
    for _ in 0..5 {
        for marker in MARKERS {
            if current.join(marker).exists() {
                return Some(current);
            }
        }
        if !current.pop() {
            break;
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::daemon::config_monitor::cache::CacheEntry;
    use std::time::Instant;

    fn cache_entry(path: &str, kind: &str, content: u8) -> CacheEntry {
        CacheEntry {
            path: PathBuf::from(path),
            subpath: None,
            path_hash: Sha256::digest(path.as_bytes()).into(),
            content_hash: [content; 32],
            last_observed: Instant::now(),
            kind: kind.into(),
            agent: "claude-code".into(),
        }
    }

    #[tokio::test]
    async fn enrich_populates_declared_sources_from_cache() {
        let cache = ContentHashCache::new(64);
        cache.insert(cache_entry("/etc/claude/.mcp.json", "mcp", 1));
        cache.insert(cache_entry("/etc/claude/skills/a.md", "skill", 2));
        cache.insert(cache_entry("/etc/claude/CLAUDE.md", "rules", 3));

        let mut data = Some(serde_json::json!({"cwd": "/tmp/test"}));
        enrich_session_start(&mut data, &cache, None).await.unwrap();

        let data = data.unwrap();
        let mcp = data.get("openlatch.declared_mcp_sources").unwrap();
        assert_eq!(mcp.as_array().unwrap().len(), 1);
        assert_eq!(
            mcp[0]["content_hash"].as_str().unwrap(),
            hex::encode([1u8; 32])
        );
        let skills = data.get("openlatch.declared_skills").unwrap();
        assert_eq!(skills.as_array().unwrap().len(), 1);
        let rules_hash = data.get("openlatch.declared_rules_hash").unwrap();
        assert!(rules_hash.is_string());
        assert!(data.get("openlatch.cwd").unwrap().is_string());
    }

    #[tokio::test]
    async fn enrich_returns_err_when_cwd_missing() {
        let cache = ContentHashCache::new(64);
        let mut data = Some(serde_json::json!({}));
        let err = enrich_session_start(&mut data, &cache, None)
            .await
            .unwrap_err();
        assert!(matches!(err, EnrichError::CwdMissing));
    }

    #[test]
    fn discover_project_root_walks_up_to_git() {
        let tmp = tempfile::tempdir().unwrap();
        let project = tmp.path().join("a").join("b").join("c");
        std::fs::create_dir_all(project.join(".git")).unwrap();
        let cwd = project.join("d").join("e");
        std::fs::create_dir_all(&cwd).unwrap();
        let root = discover_project_root(&cwd).unwrap();
        assert_eq!(root, project);
    }

    #[test]
    fn discover_project_root_returns_none_outside_project() {
        // Use a synthetic non-existent path so the walk-up never finds a
        // marker (the cwd's filesystem ancestors are off-limits — they
        // could legitimately contain `.git` from the test runner's repo).
        let synthetic = PathBuf::from("/__nonexistent_openlatch_test__/a/b/c/d");
        assert!(discover_project_root(&synthetic).is_none());
    }

    #[test]
    fn rules_hash_is_order_independent() {
        let cache_a = ContentHashCache::new(64);
        cache_a.insert(cache_entry("/a/CLAUDE.md", "rules", 1));
        cache_a.insert(cache_entry("/b/CLAUDE.md", "rules", 2));
        let cache_b = ContentHashCache::new(64);
        cache_b.insert(cache_entry("/b/CLAUDE.md", "rules", 2));
        cache_b.insert(cache_entry("/a/CLAUDE.md", "rules", 1));

        let runtime = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap();
        let hash_a = runtime.block_on(async {
            let mut data = Some(serde_json::json!({"cwd": "/x"}));
            enrich_session_start(&mut data, &cache_a, None)
                .await
                .unwrap();
            data.unwrap()["openlatch.declared_rules_hash"]
                .as_str()
                .unwrap()
                .to_string()
        });
        let hash_b = runtime.block_on(async {
            let mut data = Some(serde_json::json!({"cwd": "/x"}));
            enrich_session_start(&mut data, &cache_b, None)
                .await
                .unwrap();
            data.unwrap()["openlatch.declared_rules_hash"]
                .as_str()
                .unwrap()
                .to_string()
        });
        assert_eq!(hash_a, hash_b);
    }
}