openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Cline hook-output translator.
//!
//! A deliberate copy of `codex_cli.rs`'s **shape** — which is itself a copy of
//! `claude_code.rs`'s, "not a shared abstraction" (`codex_cli.rs:3`) — and of
//! none of its contents. Cline's contract shares no field name with either.
//!
//! # Cline has two lanes, and only one of them can refuse
//!
//! | Lane | Return type | What this build does |
//! |---|---|---|
//! | **File hooks** — the ten shims in `<store>/hooks/` | `HookControl` = `{cancel, review, context, overrideInput, systemPrompt, appendMessages, replaceMessages}` (`sdk/packages/shared/src/hooks/contracts.ts:1-9`, v4.1.17) | **never denies.** Every shim prints `{}`; see `hooks::hook_files` |
//! | **Plugin** — `beforeTool` | `AgentBeforeToolResult` = `{skip, stop, reason, input, policy, appendContext}` (`sdk/packages/shared/src/agent.ts:370-383`) | the one enforcement surface, and what this file emits |
//!
//! So the output here is the **plugin** refusal `{"skip": true, "reason": …}`,
//! exactly as a plugin writes it (`sdk/examples/plugins/env-blocker.ts:109-112`),
//! and never a `HookControl` field. Emitting `cancel` — the field that *looks*
//! like a deny — would be a deny addressed to the lane that discards it.
//!
//! The derived shape is at `schemas/vendor/cline/hook-output.schema.json`;
//! Cline publishes no JSON Schema for it, which that file's `$comment` says out
//! loud.
//!
//! | Event | allow / approve / optimize | deny / block | ask |
//! |-------|---------------------------|--------------|-----|
//! | `pre_tool_use` | `{}` | `{"skip": true, "reason": …}` **with a non-empty reason** | `{}` |
//! | every other event | `{}` | `{}` | `{}` |
//!
//! # Three things this translator may never do
//!
//! 1. **Deny outside `pre_tool_use`.** The outer match is event-scoped before
//!    the decision is ever read, as both shipped translators are
//!    (`claude_code.rs`, `codex_cli.rs:63-67`) and as `hook_output/mod.rs`
//!    states as design law: post-action and notification events degrade a
//!    `block` to `{}`. Without the outer match,
//!    `translate("cline", "session_shutdown", deny)` skips a tool call that
//!    already ran — on an event that has no tool call at all.
//! 2. **Match `deny` alone.** The daemon's wire verdict is `block`; `deny` is
//!    the pre-D14 read-alias. Matching one spelling and not the other is not a
//!    hypothetical: it is the live defect that rendered *every* Codex deny as
//!    `{}` (fixed at `codex_cli.rs`'s `("block" | "deny", …)` arms — copied
//!    here, not re-derived).
//! 3. **Skip without a reason.** The reason is the entire channel back to the
//!    model: a bare `{"skip": true}` cancels the call and tells it nothing, so
//!    it retries the same tool immediately. `DEFAULT_DENY_REASON` is therefore
//!    mandatory and non-empty — a verdict that arrived without a reason is a
//!    policy-authoring gap, never a reason to risk the one output that has to
//!    land.

use super::{empty, Verdict};
use serde_json::{json, Value};

/// The reason attached to a deny that arrived without one.
///
/// Cline surfaces it to the model in place of the tool result, so an empty
/// string is a silent cancellation the model reads as a transient failure.
const DEFAULT_DENY_REASON: &str = "Blocked by OpenLatch policy.";

/// Translate a Cline verdict for the named event type.
///
/// `pre_tool_use` is the only event with an actionable refusal channel.
/// Everything else — `task_start`, `task_resume`, `task_cancel`,
/// `task_complete`, `task_error`, `post_tool_use`, `user_prompt_submit`,
/// `pre_compact`, `session_shutdown`, and any event a later Cline release adds
/// — degrades to `{}`, the universal continue-normally signal.
pub fn translate(event: &str, verdict: &Verdict<'_>) -> Value {
    match event {
        "pre_tool_use" => pre_tool_use(verdict),
        _ => empty(),
    }
}

fn pre_tool_use(verdict: &Verdict<'_>) -> Value {
    match (verdict.decision, verdict.context) {
        ("block" | "deny", Some(ctx)) => skip(&format!("{}: {}", ctx.headline, ctx.body)),
        ("block" | "deny", None) => skip(verdict.reason.unwrap_or(DEFAULT_DENY_REASON)),

        // `("allow", _)` IS NOT A DEGRADED ASK, for the reason spelled out on
        // `codex_cli::pre_tool_use`: the daemon's `attach_alert_context`
        // produces allow + reason + context for a queued config-monitor alert,
        // and it means "a config file changed", not "a rule asked". Rendering
        // it would inject a `<hook_context>` message on every Cline tool call
        // while an alert is queued that a Claude user never sees.
        ("allow", _) => empty(),

        // Ask degrades to allow-and-flag until OQ-3.5 settles whether Cline
        // surfaces an approval prompt at all. The candidate channel,
        // `review: true`, is `HookControl` — the FILE lane, which this build
        // never denies on — so claiming a native Ask here would express it into
        // a lane that discards it. `appendContext` could carry the reason as
        // text, but text the model reads is not an approval the developer
        // grants, and a tier claimed is a tier something must deliver. The
        // binding's `expressible` is what records the degradation.
        ("ask", _) => empty(),

        // "approve" is the pre-D14 read-alias for "the user already confirmed
        // this", so it lands on allow. So does `optimize`: Cline's
        // `AgentBeforeToolResult.input` could carry a rewrite, but nothing
        // delivers one — the plugin shim forwards Cline's own parameters
        // untouched — and a verdict nothing delivers is the dishonesty this
        // unit exists to avoid. So does any wire value a future schema adds.
        _ => empty(),
    }
}

/// Delivery dispatch. **Registered in `hook_output::translate_delivery` as well
/// as `hook_output::translate`, and that is not redundant**: the vendor-schema
/// gate exercises only the first, while the live hook path
/// (`app/openlatch_hook/main.rs`) calls only the second. An agent wired into one
/// and not the other enforces in tests and nowhere else.
///
/// Cline expresses none of the delivery channels the other two agents have:
/// there is no defer, `updated_input` has no deliverer (see `pre_tool_use`), and
/// `additional_context` / `system_message` would have to ride `appendContext`,
/// which injects a user message rather than a notice. Every one of them
/// therefore renders as the verdict alone — and rendering *less* is always the
/// safe direction, because the floor is `{}`, the tool running normally. The
/// day one of them gains a deliverer it is added here, with a test that proves
/// the delivery, not the field.
pub fn translate_delivery(event: &str, verdict: &Verdict<'_>) -> Value {
    translate(event, verdict)
}

/// The plugin lane's one refusal, with the reason Cline shows the model.
///
/// A blank reason is replaced rather than forwarded: the refusal has to land
/// carrying something the model can act on.
fn skip(reason: &str) -> Value {
    let reason = if reason.trim().is_empty() {
        DEFAULT_DENY_REASON
    } else {
        reason
    };
    json!({ "skip": true, "reason": reason })
}

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

    /// Cline's ten hook events on the wire — the snake_case spellings of
    /// `hooks::hook_files::CLINE_HOOK_FILES` — plus a sentinel for whatever the
    /// next release adds.
    const EVENTS: [&str; 11] = [
        "task_start",
        "task_resume",
        "task_cancel",
        "task_complete",
        "task_error",
        "pre_tool_use",
        "post_tool_use",
        "user_prompt_submit",
        "pre_compact",
        "session_shutdown",
        "future_event_from_next_release",
    ];

    /// Every decision string the daemon can hand a translator today, plus the
    /// one Autonomy Zone D14 adds, plus a value no schema will ever carry.
    const DECISIONS: [&str; 7] = [
        "allow", "approve", "block", "deny", "ask", "optimize", "nonsense",
    ];

    fn alert() -> VerdictContext<'static> {
        VerdictContext {
            headline: "Configuration alert pending",
            body: "MCP server 'evil' was added; review before running.",
        }
    }

    /// **Both spellings reach the plugin lane's only enforced channel.** The
    /// daemon emits `block`; `deny` is the pre-D14 read-alias. A translator
    /// matching one and not the other renders `{}` for the live verdict — the
    /// silent-discard bug, proven in production on Codex.
    #[test]
    fn a_block_verdict_renders_a_skip() {
        for decision in ["block", "deny"] {
            let out = translate(
                "pre_tool_use",
                &Verdict {
                    decision,
                    reason: Some("rm -rf / is denied here"),
                    context: None,
                },
            );
            assert_eq!(
                out,
                json!({ "skip": true, "reason": "rm -rf / is denied here" }),
                "decision={decision}"
            );
            // Never the other lane's field: `cancel` is `HookControl`, which
            // the plugin's caller does not read.
            assert!(out.get("cancel").is_none(), "decision={decision}");
        }
    }

    /// A skip with no reason cancels the call and tells the model nothing, so
    /// it retries the same tool. The field is present and non-empty in all four
    /// shapes a deny can arrive in.
    #[test]
    fn a_deny_always_carries_a_reason() {
        let ctx = alert();
        let cases: [(&str, Verdict<'_>); 4] = [
            (
                "reason",
                Verdict {
                    decision: "deny",
                    reason: Some("credentials detected"),
                    context: None,
                },
            ),
            (
                "context",
                Verdict {
                    decision: "block",
                    reason: None,
                    context: Some(&ctx),
                },
            ),
            (
                "neither",
                Verdict {
                    decision: "block",
                    reason: None,
                    context: None,
                },
            ),
            (
                "empty reason",
                Verdict {
                    decision: "deny",
                    reason: Some("   "),
                    context: None,
                },
            ),
        ];

        for (label, verdict) in cases {
            let out = translate("pre_tool_use", &verdict);
            assert_eq!(out["skip"], json!(true), "{label}");
            let rendered = out["reason"]
                .as_str()
                .unwrap_or_else(|| panic!("{label}: a skip must carry a reason"));
            assert!(
                !rendered.trim().is_empty(),
                "{label}: a skip with no reason is a silent cancellation"
            );
        }

        // A context deny inlines "headline: body", so the cloud's own words
        // travel with the refusal instead of the default standing in for them.
        let with_context = translate(
            "pre_tool_use",
            &Verdict {
                decision: "block",
                reason: None,
                context: Some(&ctx),
            },
        );
        let rendered = with_context["reason"].as_str().expect("context deny");
        assert!(rendered.contains("Configuration alert pending"));
        assert!(rendered.contains("MCP server 'evil'"));
    }

    /// `{}` forever. An allow carrying a reason and a context is produced today
    /// by `attach_alert_context`; an `ask` has no channel Cline honours; an
    /// `optimize` has no deliverer. All of them are the tool running normally,
    /// and saying so out loud is a fail-open only if something could have been
    /// said instead.
    #[test]
    fn plain_allow_stays_empty() {
        let ctx = alert();
        for decision in ["allow", "approve", "ask", "optimize", "nonsense"] {
            for reason in [None, Some("a config file changed")] {
                for context in [None, Some(&ctx)] {
                    let verdict = Verdict {
                        decision,
                        reason,
                        context,
                    };
                    assert_eq!(
                        translate("pre_tool_use", &verdict),
                        empty(),
                        "decision={decision} reason={reason:?} context={}",
                        context.is_some()
                    );
                }
            }
        }
    }

    /// The event-scoped outer match, asserted directly: a deny on every event
    /// that is not `pre_tool_use` is `{}`. Without it,
    /// `translate("cline", "session_shutdown", deny)` skips a tool call that
    /// has already run — or that never existed.
    #[test]
    fn a_non_tool_event_never_skips() {
        let ctx = alert();
        for event in EVENTS.into_iter().filter(|e| *e != "pre_tool_use") {
            for decision in DECISIONS {
                for reason in [None, Some("test reason")] {
                    for context in [None, Some(&ctx)] {
                        let verdict = Verdict {
                            decision,
                            reason,
                            context,
                        };
                        assert_eq!(
                            translate(event, &verdict),
                            empty(),
                            "event={event} decision={decision} reason={reason:?} context={}",
                            context.is_some()
                        );
                    }
                }
            }
        }

        // The case the plan names, spelled out so a future refactor that drops
        // the outer match fails on a line that says why.
        assert_eq!(
            translate(
                "session_shutdown",
                &Verdict {
                    decision: "deny",
                    reason: Some("credentials detected"),
                    context: None,
                },
            ),
            empty(),
        );
    }

    /// Delivery renders the verdict and nothing more, in both directions.
    #[test]
    fn delivery_renders_the_verdict_alone() {
        assert_eq!(
            translate_delivery(
                "pre_tool_use",
                &Verdict {
                    decision: "block",
                    reason: Some("denied"),
                    context: None,
                },
            ),
            json!({ "skip": true, "reason": "denied" }),
        );
        assert_eq!(
            translate_delivery("pre_tool_use", &Verdict::allow()),
            empty()
        );
    }
}