openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::boundary::wire_format::WireFormat;
use crate::core::hook_state::marker::OpenlatchMarker;

use super::super::binding::{
    AgentBinding, BindingCapabilities, BoundaryWiring, DaemonChannel, EndpointConvention,
    FailureMode, LivenessReport,
};

/// Every hook event OpenLatch registers with Claude Code.
///
/// 9 hook-lifecycle events plus 3 config-plane events (`ConfigChange`,
/// `InstructionsLoaded`, `FileChanged`) the daemon routes into
/// `src/daemon/config_monitor/` for native-hook + FS-watcher dedup.
///
/// **This is the one list.** It used to live in `install_hooks`' body while the
/// binding declared a nine-name copy of its own, and the two drifted the moment
/// the config-plane events landed — because nothing read the binding's list.
pub const EVENT_TYPES: [&str; 12] = [
    "PreToolUse",
    "PostToolUse",
    "UserPromptSubmit",
    "Notification",
    "Stop",
    "SubagentStop",
    "PreCompact",
    "SessionStart",
    "SessionEnd",
    "ConfigChange",
    "InstructionsLoaded",
    "FileChanged",
];

/// Hook events a Claude Code install cannot function without. A settings.json
/// missing any of these is broken regardless of what the other entries look
/// like.
pub const LOAD_BEARING_EVENTS: [&str; 3] = ["PreToolUse", "UserPromptSubmit", "Stop"];

pub struct ClaudeCodeBinding {
    pub claude_dir: PathBuf,
    pub settings_path: PathBuf,
}

impl ClaudeCodeBinding {
    /// Delegates to `hooks::claude_code`, which owns directory resolution for
    /// every caller. This used to re-implement it against `dirs::home_dir()`,
    /// and that duplication is why `$CLAUDE_CONFIG_DIR` was honoured on one
    /// path and ignored on another. Resolve here and the two drift again.
    pub fn detect() -> Option<Self> {
        let claude_dir = crate::hooks::claude_code::detect()?;
        let settings_path = crate::hooks::claude_code::settings_json_path(&claude_dir);
        Some(Self {
            claude_dir,
            settings_path,
        })
    }
}

impl AgentBinding for ClaudeCodeBinding {
    fn agent_type(&self) -> &'static str {
        "claude-code"
    }

    fn display_name(&self) -> &'static str {
        "Claude Code"
    }

    fn config_dir(&self) -> PathBuf {
        self.claude_dir.clone()
    }

    fn hook_config_path(&self) -> PathBuf {
        self.settings_path.clone()
    }

    fn hook_event_types(&self) -> &'static [&'static str] {
        &EVENT_TYPES
    }

    fn load_bearing_events(&self) -> &'static [&'static str] {
        &LOAD_BEARING_EVENTS
    }

    fn daemon_channel(&self) -> DaemonChannel {
        // Claude Code forwards named environment variables: install pins the
        // values into a top-level `env` block and names them in each entry's
        // `allowedEnvVars`.
        DaemonChannel::EnvVars {
            token: crate::hooks::OPENLATCH_TOKEN_ENV,
            port: crate::hooks::OPENLATCH_PORT_ENV,
        }
    }

    fn liveness(&self) -> LivenessReport {
        // Claude Code has no trust or arming concept: installed *is* armed.
        // `None` is the honest answer, and it is not `Some(true)` — it means
        // "this agent cannot be un-armed", so doctor renders the install facts
        // and stops.
        LivenessReport {
            armed: None,
            detail: None,
            remedy: None,
            code: None,
        }
    }

    fn build_hook_entry(
        &self,
        event: &str,
        binary: &Path,
        port: u16,
        marker: &OpenlatchMarker,
    ) -> Value {
        // The token env var name is read back off this binding's own channel,
        // not from a second field beside it: one fact, one source.
        let token_env_var = match self.daemon_channel() {
            DaemonChannel::EnvVars { token, .. } => token,
            DaemonChannel::OpenlatchDirArg => crate::hooks::OPENLATCH_TOKEN_ENV,
        };
        crate::hooks::claude_code::build_hook_entry(event, port, token_env_var, binary, marker)
    }

    fn config_is_machine_global(&self) -> bool {
        // Delegate, never re-derive. Re-deriving from `self.claude_dir ==
        // ~/.claude` is the duplication that produced the earlier
        // CLAUDE_CONFIG_DIR drift.
        crate::hooks::claude_code::config_is_machine_global()
    }

    fn capabilities(&self) -> BindingCapabilities {
        // Written from observed behaviour, not aspiration.
        BindingCapabilities {
            expressible: &["allow", "ask", "deny"],
            can_mutate_arguments: false,
            native_failure_mode: FailureMode::FailOpen,
            admin_owned_settings: true,
            declares_session_in_request: true,
        }
    }

    fn boundary_wiring(&self) -> Option<BoundaryWiring> {
        Some(BoundaryWiring {
            wire_format: WireFormat::AnthropicMessages,
            endpoint: EndpointConvention::EnvVars {
                base_url: crate::hooks::ANTHROPIC_BASE_URL_ENV,
                headers: crate::hooks::ANTHROPIC_CUSTOM_HEADERS_ENV,
            },
            install_id_header: "x-openlatch-install-id",
        })
    }
}

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

    #[test]
    fn claude_code_agent_type() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        assert_eq!(b.agent_type(), "claude-code");
    }

    /// A **pin**, not a cross-check. After `install_hooks` reads
    /// `binding.hook_event_types()` there is exactly one list, so comparing it
    /// to "what install writes" would assert `x == x`. Pinning the twelve names
    /// is what stops the 9-versus-12 drift recurring: the binding used to
    /// declare a nine-name copy nothing read, and the three config-plane events
    /// were added to the other copy alone.
    #[test]
    fn binding_event_list_is_the_twelve_install_writes() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        assert_eq!(
            b.hook_event_types(),
            [
                "PreToolUse",
                "PostToolUse",
                "UserPromptSubmit",
                "Notification",
                "Stop",
                "SubagentStop",
                "PreCompact",
                "SessionStart",
                "SessionEnd",
                "ConfigChange",
                "InstructionsLoaded",
                "FileChanged",
            ],
            "the one list install writes — the three config-plane events included"
        );
    }

    /// The byte-identical proof for the load-bearing-events move: the binding
    /// answers exactly the three names that used to live in `hooks::health` as
    /// a shared constant applied to every agent.
    #[test]
    fn claude_load_bearing_events_are_the_same_three() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        assert_eq!(
            b.load_bearing_events(),
            ["PreToolUse", "UserPromptSubmit", "Stop"]
        );
    }

    /// Claude Code has no arming concept — installed *is* armed — so the honest
    /// answer is `None`, not `Some(true)`. Rendering "cannot be un-armed" as a
    /// proven `true` is the claim *off is never a pass* exists to stop, and the
    /// `None` is also what keeps the liveness renderer silent here.
    #[test]
    fn claude_liveness_is_none_not_true() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        let report = b.liveness();
        assert_eq!(
            report.armed, None,
            "installed is armed — there is nothing to prove"
        );
        assert!(report.detail.is_none());
        assert!(report.remedy.is_none());
        assert!(report.code.is_none());
    }

    /// `$CLAUDE_CONFIG_DIR` relocates the settings file this binding writes —
    /// the gate that keeps a test sandbox, and a user who has relocated their
    /// Claude config, from resolving to the real `~/.claude`. See
    /// `hooks::claude_code::detect` for why the seam has to be an env var.
    ///
    /// Runs under `daemon::identity`'s `ENV_LOCK` and `EnvGuard` rather than a
    /// private lock of its own: `CLAUDE_CONFIG_DIR` is already in that guard's
    /// `MANAGED` list, and a second lock over the same variable would not
    /// exclude the first — the two would interleave in this same test binary.
    /// `blocking_lock` is correct here because this test has no async runtime.
    #[test]
    fn claude_config_dir_relocates_the_settings_path() {
        let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
        let env = crate::daemon::identity::test_support::EnvGuard::clear();
        let dir = tempfile::tempdir().expect("temp dir");

        env.set("CLAUDE_CONFIG_DIR", dir.path());
        let b = ClaudeCodeBinding::detect().expect("relocated dir exists, so detect must succeed");
        assert_eq!(b.claude_dir, dir.path());
        assert_eq!(b.hook_config_path(), dir.path().join("settings.json"));

        // Empty is "unset" — otherwise an exported-but-blank variable would
        // resolve the config directory to "".
        env.set("CLAUDE_CONFIG_DIR", "");
        if let Some(b) = ClaudeCodeBinding::detect() {
            assert_ne!(b.claude_dir, std::path::Path::new(""));
        }
    }

    #[test]
    fn claude_code_binding_is_arc_dyn_compatible() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/tmp/.claude"),
            settings_path: PathBuf::from("/tmp/.claude/settings.json"),
        };
        let _arc: Arc<dyn AgentBinding> = Arc::new(b);
    }
}