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
//! ASK delivery by mode (AZ-4 §6.10, D-11, D-12) and what the daemon reads of a
//! binding's capabilities to do it.
//!
//! The evaluator ranks several ASKs and names the one that decides
//! ([`crate::zone_eval::join`]); this module turns that one ASK into what the
//! agent receives:
//!
//! | Delivery | Agent receives | Wire |
//! | -------- | -------------- | ---- |
//! | `Agent` | its own prompt — an `ask` verdict, which each binding's translator renders | `olverdict = ask`, `olaskdelivery = agent` |
//! | `Fallback(Block)` | the binding's refusal, reason `[OL-A…] Awaiting review` | `olverdict = ask`, `olresult = blocked`, `olaskdelivery = fallback` |
//! | `Fallback(Allow)` | the action, plus a non-blocking `[OL-A…]` notice where the binding has `notice` | `olverdict = ask`, `olresult = flagged`, `olaskdelivery = fallback` |
//!
//! An artifact that declares no `ask_mode` (a bundle below contract v3) resolves to
//! nothing here, and its ASKs are delivered exactly as they were.

use serde_json::Value;

use crate::core::envelope::HookEventType;
use crate::hooks::DetectedAgent;
use crate::zone_eval::join::{AskDelivery, AskFallback};
use crate::zone_eval::{Decision, LoadedArtifact};

/// How the one deciding ASK reaches the agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AskOutcome {
    /// The artifact's `ask_mode`, as authored.
    pub mode: String,
    pub delivery: AskDelivery,
    /// Whether the agent can show a non-blocking message.
    pub notice: bool,
    /// `[OL-A<artifact>]`, the tag the developer can quote.
    pub tag: String,
}

impl AskOutcome {
    /// The delivery of `decision`, or `None` when it is not an enforced ASK whose
    /// artifact declares an ask policy.
    pub fn resolve(
        decision: &Decision,
        artifact: Option<&LoadedArtifact>,
        can_ask: bool,
        notice: bool,
    ) -> Option<Self> {
        if decision.verdict != crate::generated::types::Verdict::Ask
            || decision.mode.as_ref().map(|mode| mode.0.as_str()) != Some("enforce")
        {
            return None;
        }
        let policy = artifact?.ask_policy()?;
        Some(Self {
            delivery: policy.delivery(can_ask),
            mode: policy.mode,
            notice,
            tag: format!(
                "[OL-A{}]",
                decision.artifact_id.as_deref().unwrap_or_default()
            ),
        })
    }

    /// `olaskdelivery`.
    pub fn delivery_token(&self) -> &'static str {
        match self.delivery {
            AskDelivery::Agent => "agent",
            AskDelivery::Fallback(_) => "fallback",
        }
    }

    /// Whether the agent is refused: the `block` fallback.
    pub fn refuses(&self) -> bool {
        self.delivery == AskDelivery::Fallback(AskFallback::Block)
    }

    /// The refusal's reason.
    pub fn refusal_reason(&self) -> String {
        format!("{} Awaiting review", self.tag)
    }

    /// The non-blocking notice of the `allow` fallback, when the agent can show one.
    pub fn notice_text(&self) -> Option<String> {
        (self.delivery == AskDelivery::Fallback(AskFallback::Allow) && self.notice)
            .then(|| format!("Flagged for review {}", self.tag))
    }
}

/// What the agent behind one event can say.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EventCaps {
    /// Can prompt the developer on this event.
    pub ask: bool,
    /// Can show a non-blocking message.
    pub notice: bool,
}

impl EventCaps {
    /// The set the evaluator reads from the event: `allow` and `deny` always, `ask`
    /// where the agent can prompt.
    pub fn expressible(&self) -> Vec<String> {
        let mut set = vec!["allow".to_string()];
        if self.ask {
            set.push("ask".to_string());
        }
        set.push("deny".to_string());
        set
    }
}

/// The capabilities of the binding `source` names, for the event `data` carries.
///
/// An agent's native prompt is a `pre_tool_use` answer: on any other event type
/// (a prompt submit, a session start) no translator renders an `ask`, so it
/// cannot prompt there whatever its binding declares.
///
/// A source with no detected binding says nothing: it cannot prompt and shows no
/// notice, so its ASKs take their authored fallback — the safe direction, never a
/// prompt the agent cannot render.
pub fn event_caps(
    bindings: &[DetectedAgent],
    source: &str,
    event_type: &HookEventType,
    data: Option<&Value>,
) -> EventCaps {
    let Some(binding) = bindings
        .iter()
        .map(|agent| &agent.binding)
        .find(|binding| binding.agent_type() == source)
    else {
        return EventCaps::default();
    };
    let caps = binding.capabilities();
    EventCaps {
        // Cursor's `ask` is native on its shell and MCP events only; the wire type
        // cannot say which, so the payload does (`super::handlers`).
        ask: caps.ask()
            && *event_type == HookEventType::PreToolUse
            && !data.is_some_and(super::handlers::cursor_event_cannot_ask),
        notice: caps.notice,
    }
}

/// The `OpenLatch-Client-Capabilities` tokens of the active bindings (AZ-4 D-9):
/// `binding.<olbinding>.ask`, `.notice`, `.observes_execution` where true, and
/// `.enforces.<all|shell_only>` always.
pub fn capability_tokens(bindings: &[DetectedAgent]) -> Vec<String> {
    let mut tokens = Vec::new();
    for agent in bindings {
        let binding = &agent.binding;
        let caps = binding.capabilities();
        // The lane that decides: the plugin where the agent has one, else its hook.
        let id = binding.binding_id(binding.plugin_surface().is_some());
        let prefix = format!("binding.{id}");
        if caps.ask() {
            tokens.push(format!("{prefix}.ask"));
        }
        if caps.notice {
            tokens.push(format!("{prefix}.notice"));
        }
        if caps.observes_execution {
            tokens.push(format!("{prefix}.observes_execution"));
        }
        tokens.push(format!("{prefix}.enforces.{}", caps.enforces.token()));
    }
    tokens
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::generated::types::{PolicyMode, Verdict};

    fn artifact(mode: &str, fallback: &str) -> LoadedArtifact {
        let bundle = crate::zone_eval::load(serde_json::json!({
            "schema_version": 2,
            "contract_version": 3,
            "organization_id": "org",
            "revision": 1,
            "built_at": "2026-09-01T00:00:00Z",
            "enforcement_enabled": true,
            "signature": null,
            "artifacts": [{
                "artifact_id": "a1", "atom_id": "x", "kind": "t1_predicate_tree",
                "verdict": "ask", "mode": "enforce", "tier": 1,
                "ask_mode": mode, "ask_fallback": fallback,
                "body": {
                    "node": {"op": "leaf", "leaf": {"pred": "equals", "field": "tool.name", "value": "Bash"}},
                    "verdict": "ask", "reason": "confirm",
                },
            }],
        }))
        .expect("the bundle loads");
        bundle.artifacts.into_iter().next().expect("one artifact")
    }

    fn asked(mode: &str) -> Decision {
        Decision {
            verdict: Verdict::Ask,
            artifact_id: Some("a1".into()),
            mode: Some(PolicyMode(mode.into())),
            ..Decision::default()
        }
    }

    fn outcome(mode: &str, fallback: &str, can_ask: bool) -> AskOutcome {
        AskOutcome::resolve(
            &asked("enforce"),
            Some(&artifact(mode, fallback)),
            can_ask,
            true,
        )
        .expect("an enforced ASK with an ask policy resolves")
    }

    /// AC-7: the delivery table, every mode against both capabilities and both
    /// fallbacks.
    #[test]
    fn delivery_follows_mode_capability_and_fallback() {
        for fallback in ["allow", "block"] {
            let fb = if fallback == "block" {
                AskDelivery::Fallback(AskFallback::Block)
            } else {
                AskDelivery::Fallback(AskFallback::Allow)
            };
            // platform: the fallback, whatever the agent can do.
            assert_eq!(outcome("platform", fallback, true).delivery, fb);
            assert_eq!(outcome("platform", fallback, false).delivery, fb);
            // agent and both: the prompt where the agent can, the fallback where not.
            for mode in ["agent", "both"] {
                assert_eq!(outcome(mode, fallback, true).delivery, AskDelivery::Agent);
                assert_eq!(outcome(mode, fallback, false).delivery, fb);
            }
        }
    }

    #[test]
    fn the_fallback_block_refuses_with_its_tag_and_the_allow_fallback_only_notifies() {
        let block = outcome("platform", "block", true);
        assert!(block.refuses());
        assert_eq!(block.refusal_reason(), "[OL-Aa1] Awaiting review");
        assert_eq!(block.notice_text(), None);
        assert_eq!(block.delivery_token(), "fallback");

        let allow = outcome("platform", "allow", true);
        assert!(!allow.refuses());
        assert_eq!(
            allow.notice_text().as_deref(),
            Some("Flagged for review [OL-Aa1]")
        );

        let mut silent = outcome("platform", "allow", true);
        silent.notice = false;
        assert_eq!(
            silent.notice_text(),
            None,
            "no notice where the agent has none"
        );

        let prompt = outcome("agent", "block", true);
        assert_eq!(prompt.delivery_token(), "agent");
        assert!(!prompt.refuses());
        assert_eq!(prompt.notice_text(), None);
    }

    /// AC-8 and "Monitor ASKs deliver nothing": no ask policy, or not an enforced
    /// ASK, resolves to nothing, so delivery is what it was.
    #[test]
    fn nothing_resolves_without_an_ask_policy_or_outside_enforce() {
        let v2 = crate::zone_eval::load(serde_json::json!({
            "schema_version": 2, "organization_id": "org", "revision": 1,
            "built_at": "2026-09-01T00:00:00Z", "enforcement_enabled": true,
            "signature": null,
            "artifacts": [{
                "artifact_id": "a1", "atom_id": "x", "kind": "t1_predicate_tree",
                "verdict": "ask", "mode": "enforce", "tier": 1,
                "body": {
                    "node": {"op": "leaf", "leaf": {"pred": "equals", "field": "tool.name", "value": "Bash"}},
                    "verdict": "ask", "reason": "confirm",
                },
            }],
        }))
        .expect("loads");
        let legacy = v2.artifacts.first();
        assert_eq!(
            AskOutcome::resolve(&asked("enforce"), legacy, true, true),
            None
        );
        assert_eq!(
            AskOutcome::resolve(
                &asked("monitor"),
                Some(&artifact("platform", "block")),
                true,
                true
            ),
            None,
            "a monitor ASK delivers nothing"
        );
        let mut allowed = asked("enforce");
        allowed.verdict = Verdict::Allow;
        assert_eq!(
            AskOutcome::resolve(&allowed, Some(&artifact("platform", "block")), true, true),
            None
        );
        assert_eq!(
            AskOutcome::resolve(&asked("enforce"), None, true, true),
            None
        );
    }

    #[test]
    fn expressible_holds_ask_only_where_the_agent_can_prompt() {
        assert_eq!(
            EventCaps {
                ask: true,
                notice: true
            }
            .expressible(),
            ["allow", "ask", "deny"]
        );
        assert_eq!(
            EventCaps {
                ask: false,
                notice: true
            }
            .expressible(),
            ["allow", "deny"]
        );
    }

    #[test]
    fn an_undetected_source_cannot_prompt_and_shows_no_notice() {
        assert_eq!(
            event_caps(&[], "claude-code", &HookEventType::PreToolUse, None),
            EventCaps::default()
        );
    }
}