openlatch-client 0.6.6

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::core::hook_state::marker::OpenlatchMarker;
use crate::model_relay::wire_format::WireFormat;

use super::super::binding::{
    AgentBinding, BindingCapabilities, DaemonChannel, EndpointConvention, Enforces, FailureMode,
    LivenessReport, ModelRelayWiring,
};
use crate::core::envelope::alias::{AliasTable, McpKeyRule, MultiValueRule};

/// Claude Code's alias table (D-I16-22).
///
/// OpenLatch's canonical vocabulary is borrowed from this agent, so every row
/// is an identity row: the table's job here is to say which tools are
/// **covered** — a tool absent from it (`Skill`, `KillShell`, …) is unmapped on
/// purpose and is learned as unknown. MCP arrives already spelled
/// `mcp__<server>__<tool>`. `PowerShell`, `AskUserQuestion`, `TodoWrite` and
/// `ExitPlanMode` are canonical built-in rows of the evaluator, so they are
/// listed too.
pub static ALIASES: AliasTable = AliasTable {
    agent: "claude-code",
    binding: "claude_code_hook",
    renders_binding: None,
    tools: &[
        ("Bash", "Bash"),
        ("BashOutput", "BashOutput"),
        ("PowerShell", "PowerShell"),
        ("Read", "Read"),
        ("Write", "Write"),
        ("Edit", "Edit"),
        ("MultiEdit", "MultiEdit"),
        ("NotebookEdit", "NotebookEdit"),
        ("Glob", "Glob"),
        ("Grep", "Grep"),
        ("WebFetch", "WebFetch"),
        ("WebSearch", "WebSearch"),
        ("Task", "Task"),
        ("Agent", "Agent"),
        ("AskUserQuestion", "AskUserQuestion"),
        ("TodoWrite", "TodoWrite"),
        ("ExitPlanMode", "ExitPlanMode"),
    ],
    event_tools: &[],
    keep: &[],
    inputs: &[],
    creates: &[],
    mcp_rule: McpKeyRule::NATIVE,
    multi: MultiValueRule::NONE,
};

/// 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; 13] = [
    "PreToolUse",
    "PostToolUse",
    "PostToolUseFailure",
    "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 aliases(&self) -> &'static AliasTable {
        &ALIASES
    }

    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,
            off: false,
            pending: false,
        }
    }

    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: true,
            native_failure_mode: FailureMode::FailOpen,
            admin_owned_settings: true,
            declares_session_in_request: true,
            notice: true,
            observes_execution: true,
            enforces: Enforces::All,
        }
    }

    fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
        Some(ModelRelayWiring {
            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 thirteen names
    /// is what stops event-list 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_thirteen_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",
                "PostToolUseFailure",
                "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);
    }

    /// D-9, pinned: every capability, with `ask` derived from `expressible`.
    #[test]
    fn capabilities_are_the_pinned_values() {
        let b = ClaudeCodeBinding {
            claude_dir: PathBuf::from("/home/test/.claude"),
            settings_path: PathBuf::from("/home/test/.claude/settings.json"),
        };
        let c = b.capabilities();
        assert!(c.ask(), "ask is derived from `expressible`, which holds it");
        assert!(c.notice && c.observes_execution);
        assert_eq!(c.enforces, Enforces::All);
    }

    /// D-7 / AC-2, AC-3: the canonical vocabulary is this agent's own, so every
    /// row is an identity row, MCP arrives canonical, and an unlisted tool is
    /// unmapped (learned as unknown) under this binding's id.
    #[test]
    fn aliases_are_identity_rows_and_an_unlisted_tool_is_unmapped() {
        for tool in [
            "Bash",
            "BashOutput",
            "Read",
            "Write",
            "Edit",
            "MultiEdit",
            "NotebookEdit",
            "Glob",
            "Grep",
            "WebFetch",
            "WebSearch",
            "Task",
            "Agent",
        ] {
            let call = ALIASES.resolve(tool, Some(serde_json::json!({"k": "v"})), None);
            assert_eq!((call.name.as_str(), call.mapped), (tool, true), "{tool}");
            assert_eq!(call.input, Some(serde_json::json!({"k": "v"})), "{tool}");
        }
        let mcp = ALIASES.resolve("mcp__github__create_issue", None, None);
        assert_eq!(mcp.name, "mcp__github__create_issue");
        assert!(mcp.mapped);
        let other = ALIASES.resolve("KillShell", None, None);
        assert!(!other.mapped);
        assert_eq!(other.native, "KillShell");
        assert_eq!(ALIASES.binding_id(false), "claude_code_hook");
        assert_eq!(ALIASES.binding_id(true), "claude_code_hook");
    }
}