openlatch-client 0.6.13

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! 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::binding::AgentBinding;
use crate::hooks::bindings::opencode::OpencodeBinding;
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)]
pub struct EventCaps {
    /// Can prompt the developer on this event.
    pub ask: bool,
    /// Can show a non-blocking message.
    pub notice: bool,
    /// Can wait on a held Ask (AGT-5 D-10): the daemon registers a hold only
    /// where this is true. A source no detected binding answers for keeps
    /// today's behaviour — `true`.
    pub hold: bool,
}

impl Default for EventCaps {
    fn default() -> Self {
        Self {
            ask: false,
            notice: false,
            hold: true,
        }
    }
}

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 detected = bindings
        .iter()
        .map(|agent| &agent.binding)
        .find(|binding| binding.agent_type() == source);
    if let Some(binding) = detected {
        return caps_of(binding.as_ref(), event_type, data);
    }
    // OpenCode's capabilities are fixed by its type, not by where it is installed,
    // and its plugin posts events whatever `PATH` the daemon's detection saw: a
    // missed detection must not hand it the default `hold: true` its 1500 ms
    // budget would cut short (AGT-5 D-10).
    if source == OpencodeBinding::AGENT_TYPE {
        let binding = OpencodeBinding {
            root: std::path::PathBuf::new(),
        };
        return caps_of(&binding, event_type, data);
    }
    EventCaps::default()
}

fn caps_of(
    binding: &dyn AgentBinding,
    event_type: &HookEventType,
    data: Option<&Value>,
) -> EventCaps {
    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,
        hold: caps.can_hold,
    }
}

/// 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,
                hold: true,
            }
            .expressible(),
            ["allow", "ask", "deny"]
        );
        assert_eq!(
            EventCaps {
                ask: false,
                notice: true,
                hold: 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()
        );
        assert!(
            EventCaps::default().hold,
            "an undetected source keeps today's hold registration"
        );
    }

    /// AGT-5 D-10: an OpenCode whose CLI the daemon's `PATH` cannot find still
    /// posts events through its plugin; with no detected binding it must never
    /// fall back to the default `hold: true`.
    #[test]
    fn an_undetected_opencode_still_never_holds() {
        let caps = event_caps(&[], "opencode", &HookEventType::PreToolUse, None);
        assert!(!caps.hold);
        assert!(!caps.ask);
        assert!(!caps.notice);
    }

    /// AGT-5 D-10: the hold follows the binding's declaration — OpenCode cannot
    /// wait on one; the four agents wired before it still can.
    #[test]
    fn can_hold_follows_the_binding_declaration() {
        let root = tempfile::tempdir().expect("tempdir");
        let opencode = crate::hooks::DetectedAgent {
            kind: crate::hooks::AgentKind::Opencode,
            binding: std::sync::Arc::new(crate::hooks::bindings::opencode::OpencodeBinding {
                root: root.path().join("opencode"),
            }),
        };
        let caps = event_caps(&[opencode], "opencode", &HookEventType::PreToolUse, None);
        assert!(!caps.hold);
        assert!(!caps.ask);
        assert!(!caps.notice);

        let claude = crate::hooks::DetectedAgent {
            kind: crate::hooks::AgentKind::ClaudeCode,
            binding: std::sync::Arc::new(crate::hooks::bindings::claude_code::ClaudeCodeBinding {
                settings_path: root.path().join("claude/settings.json"),
                claude_dir: root.path().join("claude"),
            }),
        };
        assert!(event_caps(&[claude], "claude-code", &HookEventType::PreToolUse, None).hold);
    }
}