openlatch-client 0.6.10

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Which of Claude Code's cached plugin files are a plugin an agent can load.
//!
//! `<claude dir>/plugins/cache/<marketplace>/<plugin>/<version>/` keeps every
//! version a plugin was ever updated to. An agent loads one of them: the
//! `installPath` that `plugins/installed_plugins.json` records for each
//! install. Claude Code reads them so, and so does Cursor, which imports
//! Claude Code's plugins. A manifest entry marked `claude_plugins` reports
//! only files under those roots — the older versions are files on disk that
//! nothing can run.
//!
//! **Only the version is filtered, never the install.** Every scope counts — a
//! `project` or `local` install is loaded in its project — and a plugin
//! `enabledPlugins` turns off is still reported: project, local and managed
//! settings can turn it back on, and an inventory that trusted one of them
//! would go blind to a plugin that runs.
//!
//! An unreadable registry filters nothing. Over-reporting stale copies is the
//! old behavior; going silent on every plugin because one file failed to parse
//! would be a new, worse one.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::path_compat::dedup_key;

/// Whether `path` is a file an agent can load: under the install root of an
/// installed plugin — or not a plugin cache file at all.
pub(super) fn admits(path: &Path) -> bool {
    Active::default().admits(path)
}

/// Keeps the paths of `paths` that [`admits`] would, reading each Claude
/// directory's registry once.
pub(super) fn retain_active(paths: &mut Vec<PathBuf>) {
    let mut active = Active::default();
    paths.retain(|path| active.admits(path));
}

/// The active install roots per Claude directory, read on first use. `None`
/// when that directory's registry cannot be read.
#[derive(Default)]
struct Active(HashMap<PathBuf, Option<HashSet<PathBuf>>>);

impl Active {
    fn admits(&mut self, path: &Path) -> bool {
        let Some((claude_dir, root)) = install_root_of(path) else {
            return true;
        };
        match self
            .0
            .entry(claude_dir.clone())
            .or_insert_with(|| active_roots(&claude_dir))
        {
            Some(roots) => roots.contains(&dedup_key(&root)),
            None => true,
        }
    }
}

/// `(<claude dir>, <claude dir>/plugins/cache/<marketplace>/<plugin>/<version>)`
/// for a path inside a plugin's version directory.
fn install_root_of(path: &Path) -> Option<(PathBuf, PathBuf)> {
    let components: Vec<_> = path.components().collect();
    let cache = components
        .windows(2)
        .position(|w| w[0].as_os_str() == "plugins" && w[1].as_os_str() == "cache")?;
    // plugins, cache, marketplace, plugin, version — and something inside it.
    if components.len() <= cache + 5 {
        return None;
    }
    let claude_dir: PathBuf = components[..cache].iter().collect();
    let root: PathBuf = components[..cache + 5].iter().collect();
    Some((claude_dir, root))
}

/// The install roots `claude_dir` records, for every plugin and scope.
fn active_roots(claude_dir: &Path) -> Option<HashSet<PathBuf>> {
    let raw =
        std::fs::read_to_string(claude_dir.join("plugins").join("installed_plugins.json")).ok()?;
    let registry: Value = serde_json::from_str(&raw).ok()?;
    Some(
        registry
            .get("plugins")?
            .as_object()?
            .values()
            .flat_map(|installs| installs.as_array().into_iter().flatten())
            .filter_map(|install| install.get("installPath").and_then(Value::as_str))
            .map(|root| dedup_key(Path::new(root)))
            .collect(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn write(path: &Path, body: &str) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, body).unwrap();
    }

    /// One plugin with a current and a stale version, one the user settings
    /// disable, one installed only for a project.
    fn claude_dir() -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        let cache = dir.path().join("plugins/cache/official");
        for version in ["new", "old"] {
            write(
                &cache.join("context7").join(version).join(".mcp.json"),
                "{}",
            );
        }
        write(&cache.join("posthog/v1/.mcp.json"), "{}");
        write(&cache.join("codex/v1/.mcp.json"), "{}");
        let root = |p: &str| cache.join(p).display().to_string();
        write(
            &dir.path().join("plugins/installed_plugins.json"),
            &serde_json::json!({"version": 2, "plugins": {
                "context7@official": [{"scope": "user", "installPath": root("context7/new")}],
                "posthog@official": [{"scope": "user", "installPath": root("posthog/v1")}],
                "codex@official": [{"scope": "local", "installPath": root("codex/v1"),
                                    "projectPath": "/work"}],
            }})
            .to_string(),
        );
        write(
            &dir.path().join("settings.json"),
            r#"{"enabledPlugins": {"context7@official": true, "posthog@official": false}}"#,
        );
        dir
    }

    #[test]
    fn only_the_installed_version_of_each_plugin_is_reported() {
        let dir = claude_dir();
        let cache = dir.path().join("plugins/cache/official");
        let mut paths = vec![
            cache.join("context7/new/.mcp.json"),
            cache.join("context7/old/.mcp.json"),
            cache.join("posthog/v1/.mcp.json"),
            cache.join("codex/v1/.mcp.json"),
        ];
        retain_active(&mut paths);
        assert_eq!(
            paths,
            vec![
                cache.join("context7/new/.mcp.json"),
                cache.join("posthog/v1/.mcp.json"),
                cache.join("codex/v1/.mcp.json"),
            ],
            "a disabled plugin and a project install are still plugins that can run"
        );
        assert!(!admits(&cache.join("context7/old/.mcp.json")));
    }

    #[test]
    fn a_path_outside_a_plugin_version_dir_is_not_filtered() {
        let dir = claude_dir();
        assert!(admits(&dir.path().join("settings.json")));
        assert!(admits(&dir.path().join("plugins/cache/official/context7")));
    }

    #[test]
    fn an_unreadable_registry_filters_nothing() {
        let dir = claude_dir();
        std::fs::write(
            dir.path().join("plugins/installed_plugins.json"),
            "not json",
        )
        .unwrap();
        assert!(admits(
            &dir.path()
                .join("plugins/cache/official/context7/old/.mcp.json")
        ));
    }
}