openlatch-client 0.6.19

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
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
//! OpenCode's binding (AGT-5).
//!
//! OpenCode is shaped like Cline's plugin lane, not like Cursor's hooks file:
//! every OpenCode client (the terminal UI, `run`, `serve`/`web`, ACP, Desktop)
//! talks to one local server process, and plugins load in that process. One
//! drop-in file, `<config root>/plugins/openlatch.js`, therefore covers every
//! surface on the host. Its surface is a [`HookSurface::PluginFile`]: there are
//! no hook entries to write, so [`AgentBinding::build_hook_entry`] is inert.
//!
//! OpenCode can refuse (the plugin throws) but cannot ask and cannot hold: an
//! Ask takes the policy author's fallback for non-prompting agents (C-2).

use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::core::envelope::alias::{AliasTable, McpKeyRule, MultiValueRule};
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::error::{ERR_OPENCODE_PLUGIN_FOREIGN, ERR_OPENCODE_PLUGIN_PENDING};
use crate::hooks::binding::{
    AgentBinding, BindingCapabilities, DaemonChannel, EndpointConvention, Enforces, EnvCommand,
    FailureMode, HookSurface, LivenessReport, ModelRelayWiring, ProxyDelivery,
};
use crate::hooks::hook_files::{self, Existing};
use crate::model_relay::host_match::HostRule;
use crate::model_relay::wire_format::WireFormat;

/// OpenCode's alias table (C-4).
///
/// The native tools are lower-case; their arguments are camelCase and are
/// renamed onto the canonical keys. `apply_patch` and the MCP split
/// (`<server>_<tool>` against the configured servers) need the payload, not a
/// static row, and are applied in `to_common`.
pub static ALIASES: AliasTable = AliasTable {
    agent: OpencodeBinding::AGENT_TYPE,
    binding: "opencode_plugin",
    renders_binding: None,
    tools: &[
        ("bash", "Bash"),
        ("edit", "Edit"),
        ("write", "Write"),
        ("read", "Read"),
        ("grep", "Grep"),
        ("glob", "Glob"),
        ("webfetch", "WebFetch"),
        ("websearch", "WebSearch"),
        ("task", "Task"),
        ("todowrite", "TodoWrite"),
        ("skill", "Skill"),
    ],
    event_tools: &[],
    keep: &[],
    inputs: &[
        ("*", "filePath", "file_path"),
        ("*", "oldString", "old_string"),
        ("*", "newString", "new_string"),
        ("*", "replaceAll", "replace_all"),
    ],
    creates: &[],
    mcp_rule: McpKeyRule::NATIVE,
    multi: MultiValueRule::NONE,
};

/// The hosts OpenCode's model traffic is intercepted on (AGT-5 C-6): a fixed constant, grown from
/// evidence. `opencode.ai` is first, so the preflight proves trust against OpenCode's own host. A
/// custom `provider.*.options.baseURL` host is never added here, whatever the config says: config
/// never decides which host our CA mints a leaf for (Plan D-11), so such a host is tunnelled.
///
/// AGT-6 C-5 adds GitHub Copilot, Groq and Google Vertex's global host. Appended, never reordered.
pub const OPENCODE_INTERCEPT_HOSTS: &[&str] = &[
    "opencode.ai",
    "api.anthropic.com",
    "api.openai.com",
    "generativelanguage.googleapis.com",
    "openrouter.ai",
    "api.githubcopilot.com",
    "api.groq.com",
    "aiplatform.googleapis.com",
];

/// The fixed one-label host patterns OpenCode's model traffic is intercepted on (AGT-6 C-5,
/// frozen): AWS Bedrock (commercial and China), Azure OpenAI and Foundry, and Google Vertex's
/// regional hosts. `<label>` is exactly one DNS label (`host_match::one_label`). Each matching
/// host gets its own leaf, minted at CONNECT into a bounded cache; no wildcard certificate exists.
/// Code only, like the exact list: no config adds a pattern.
pub const OPENCODE_INTERCEPT_PATTERNS: &[HostRule] = &[
    HostRule::OneLabel {
        prefix: "bedrock-runtime.",
        suffix: ".amazonaws.com",
    },
    HostRule::OneLabel {
        prefix: "bedrock-runtime.",
        suffix: ".amazonaws.com.cn",
    },
    HostRule::OneLabel {
        prefix: "",
        suffix: ".openai.azure.com",
    },
    HostRule::OneLabel {
        prefix: "",
        suffix: ".cognitiveservices.azure.com",
    },
    HostRule::OneLabel {
        prefix: "",
        suffix: ".services.ai.azure.com",
    },
    HostRule::OneLabel {
        prefix: "",
        suffix: "-aiplatform.googleapis.com",
    },
];

/// The variable that turns on OpenCode's WebSocket transport for OpenAI turns, which the relay
/// cannot capture (C-6). Pinned off on stable builds; a value the developer set is never
/// overwritten.
pub const WEBSOCKETS_ENV: &str = "OPENCODE_EXPERIMENTAL_WEBSOCKETS";

/// The `opencode` command, with loopback exempted from the proxy: the terminal UI talks to its
/// own server over loopback (C-6).
pub(crate) const OPENCODE_COMMANDS: &[EnvCommand] = &[EnvCommand::loopback_direct("opencode")];

/// The CLI surface's delivery (Plan D-3/D-4). Desktop has none in this build: its request plane is
/// macOS-only and gated on probe P-4b, so doctor reports `relay.desktop = unsupported`.
const OPENCODE_DELIVERY: &[ProxyDelivery] = &[ProxyDelivery::EnvFile {
    commands: OPENCODE_COMMANDS,
    defaults: OPENCODE_DEFAULTS,
}];

/// The environment every wrapped `opencode` runs with: WebSockets pinned off (C-6).
pub(crate) const OPENCODE_DEFAULTS: &[(&str, &str)] = &[(WEBSOCKETS_ENV, "false")];

/// The OpenCode hooks the plugin registers. Not written anywhere — the plugin
/// file is the registration — and listed so health and doctor speak OpenCode's
/// own names. `experimental.session.compacting` records `pre_compact` and never
/// decides (AGT-6 C-3).
const EVENT_TYPES: &[&str] = &[
    "tool.execute.before",
    "chat.message",
    "tool.execute.after",
    "event",
    "experimental.session.compacting",
];

/// The two that decide.
const LOAD_BEARING: &[&str] = &["tool.execute.before", "chat.message"];

/// OpenCode, bound at its config root.
pub struct OpencodeBinding {
    /// The config root (`$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`).
    pub root: PathBuf,
}

impl OpencodeBinding {
    /// CloudEvents `source`.
    pub const AGENT_TYPE: &'static str = "opencode";

    /// Present when the CLI or the Desktop application is installed.
    pub fn detect() -> Option<Self> {
        crate::hooks::opencode::detect().map(|root| Self { root })
    }

    /// The plugins folder.
    #[must_use]
    pub fn plugins_dir(&self) -> PathBuf {
        crate::hooks::opencode::plugins_dir(&self.root)
    }

    /// Our plugin file.
    #[must_use]
    pub fn plugin_path(&self) -> PathBuf {
        crate::hooks::opencode::plugin_path(&self.root)
    }

    /// Liveness from the two facts that decide it: what holds our path, and
    /// whether a load report arrived after the file was written.
    pub(crate) fn liveness_from(existing: Existing, seen_after_write: bool) -> LivenessReport {
        let report = |armed, detail: &str, remedy: Option<&str>, code, pending| LivenessReport {
            armed,
            detail: Some(detail.to_string()),
            remedy: remedy.map(str::to_string),
            code,
            off: false,
            pending,
            settling: false,
        };
        match existing {
            // Health reports a missing plugin as a missing registration.
            Existing::Nothing => report(
                None,
                "the OpenLatch plugin is not installed",
                None,
                None,
                false,
            ),
            Existing::Theirs => report(
                Some(false),
                "a file OpenLatch does not own holds the OpenCode plugin path",
                Some("Move that file, then rerun `openlatch init --agent opencode`."),
                Some(ERR_OPENCODE_PLUGIN_FOREIGN),
                false,
            ),
            Existing::Ours if seen_after_write => report(
                Some(true),
                "OpenCode loaded the OpenLatch plugin",
                None,
                None,
                false,
            ),
            Existing::Ours => report(
                Some(false),
                "the plugin is installed and OpenCode has not loaded it since",
                Some("Restart OpenCode; this check turns green on the plugin's first load report."),
                Some(ERR_OPENCODE_PLUGIN_PENDING),
                true,
            ),
        }
    }
}

impl AgentBinding for OpencodeBinding {
    fn agent_type(&self) -> &'static str {
        Self::AGENT_TYPE
    }

    fn aliases(&self) -> &'static AliasTable {
        &ALIASES
    }

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

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

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

    fn hook_surface(&self) -> HookSurface {
        HookSurface::PluginFile(self.plugin_path())
    }

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

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

    fn daemon_channel(&self) -> DaemonChannel {
        // The plugin passes `--openlatch-dir`; the hook reads port and token
        // from there. No secret is ever rendered into the plugin file.
        DaemonChannel::OpenlatchDirArg
    }

    fn liveness(&self) -> LivenessReport {
        let path = self.plugin_path();
        let existing = hook_files::whatever_is_there(&path);
        let ol_dir = crate::config::openlatch_dir();
        let seen = crate::daemon::opencode_plugin_seen::read_seen(&ol_dir);
        let seen_after_write = crate::daemon::opencode_plugin_seen::mtime_ms(&path)
            .is_some_and(|written| crate::daemon::opencode_plugin_seen::seen_since(&seen, written));
        Self::liveness_from(existing, seen_after_write)
    }

    fn build_hook_entry(
        &self,
        _event: &str,
        _binary: &Path,
        _port: u16,
        _marker: &OpenlatchMarker,
    ) -> Value {
        // Inert, never a panic: a plugin-file surface writes no entries, and
        // `install_hooks` dispatches it before any entry is built.
        json!({})
    }

    fn config_is_machine_global(&self) -> bool {
        crate::hooks::opencode::config_is_machine_global()
    }

    fn capabilities(&self) -> BindingCapabilities {
        BindingCapabilities {
            // Block is a throw; Ask has no lane (OpenCode never calls its
            // `permission.ask` plugin hook), so it is not expressible here and
            // takes the author's fallback for non-prompting agents.
            expressible: &["allow", "deny"],
            can_mutate_arguments: false,
            native_failure_mode: FailureMode::FailOpen,
            admin_owned_settings: false,
            declares_session_in_request: true,
            notice: false,
            observes_execution: true,
            enforces: Enforces::All,
            // The plugin's 1500 ms budget would cut any hold short.
            can_hold: false,
        }
    }

    #[cfg(feature = "model-relay")]
    fn relay_surfaces(&self) -> Vec<crate::model_relay::readiness::SurfaceDecl> {
        dirs::home_dir()
            .map(|h| crate::hooks::relay_surfaces::opencode(&h))
            .unwrap_or_default()
    }

    fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
        // Intercepted (Plan D-3): fixed hosts only, the `opencode` command pointed at the relay by
        // the generated env file. No `provider_endpoints()`, so no loopback rewrite and no dynamic
        // host — no `opencode.json` is ever edited. The format is a placeholder a ProxyEnv plane
        // never records: each request's own route decides its format.
        Some(ModelRelayWiring {
            wire_format: WireFormat::OpenAiChatCompletions,
            endpoint: EndpointConvention::ProxyEnv {
                intercept_hosts: OPENCODE_INTERCEPT_HOSTS,
                delivery: OPENCODE_DELIVERY,
                h2_hosts: &[],
            },
            install_id_header: "x-openlatch-install-id",
        })
    }

    fn intercept_patterns(&self) -> &'static [HostRule] {
        OPENCODE_INTERCEPT_PATTERNS
    }

    fn tamper_watch_paths(&self) -> Vec<PathBuf> {
        vec![self.plugin_path()]
    }
}

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

    fn binding() -> OpencodeBinding {
        OpencodeBinding {
            root: PathBuf::from("/home/test/.config/opencode"),
        }
    }

    #[test]
    fn surface_is_the_plugin_file() {
        let b = binding();
        assert_eq!(
            b.hook_surface(),
            HookSurface::PluginFile(
                PathBuf::from("/home/test/.config/opencode")
                    .join("plugins")
                    .join("openlatch.js")
            )
        );
        assert_eq!(b.hook_config_path(), b.plugin_path());
    }

    #[test]
    fn opencode_cannot_ask_or_hold_and_blocks_every_tool() {
        let caps = binding().capabilities();
        assert!(!caps.ask());
        assert!(!caps.can_hold);
        assert!(!caps.can_mutate_arguments);
        assert!(caps.expressible.contains(&"deny"));
        assert_eq!(caps.enforces, Enforces::All);
    }

    /// AGT-6 C-3: the compaction hook is registered and records only; the two
    /// deciding hooks are unchanged.
    #[test]
    fn the_compaction_hook_is_registered_and_never_load_bearing() {
        let b = binding();
        assert!(b
            .hook_event_types()
            .contains(&"experimental.session.compacting"));
        assert_eq!(
            b.load_bearing_events(),
            ["tool.execute.before", "chat.message"]
        );
        assert!(b
            .load_bearing_events()
            .iter()
            .all(|e| b.hook_event_types().contains(e)));
    }

    #[test]
    fn the_binding_id_is_opencode_plugin() {
        assert_eq!(binding().binding_id(false), "opencode_plugin");
        assert_eq!(
            crate::hooks::bindings::binding_id("opencode", false),
            "opencode_plugin"
        );
    }

    #[test]
    fn liveness_is_pending_until_a_load_report_follows_the_write() {
        let pending = OpencodeBinding::liveness_from(Existing::Ours, false);
        assert_eq!(pending.armed, Some(false));
        assert!(pending.pending);
        assert_eq!(pending.code, Some(ERR_OPENCODE_PLUGIN_PENDING));
        assert!(pending.remedy.is_some());

        let seen = OpencodeBinding::liveness_from(Existing::Ours, true);
        assert_eq!(seen.armed, Some(true));

        let foreign = OpencodeBinding::liveness_from(Existing::Theirs, true);
        assert_eq!(foreign.code, Some(ERR_OPENCODE_PLUGIN_FOREIGN));
        assert!(foreign.remedy.is_some());
    }

    /// Plan D-3/D-4/D-11: one intercepted lane on fixed hosts, the `opencode` command wrapped
    /// with loopback exempted and the WebSocket pin as a default, no provider slots (so the
    /// endpoint wiring skips OpenCode) and no h2 host.
    #[test]
    fn the_request_plane_is_proxy_env_on_fixed_hosts() {
        let b = binding();
        assert!(b.provider_endpoints().is_none(), "no base-URL lane (D-3)");
        match b
            .model_relay_wiring()
            .expect("OpenCode declares ProxyEnv")
            .endpoint
        {
            EndpointConvention::ProxyEnv {
                intercept_hosts,
                delivery,
                h2_hosts,
            } => {
                assert_eq!(intercept_hosts, OPENCODE_INTERCEPT_HOSTS);
                assert_eq!(intercept_hosts[0], "opencode.ai", "the probe host is first");
                assert!(h2_hosts.is_empty());
                assert!(intercept_hosts
                    .iter()
                    .all(|h| *h == h.to_ascii_lowercase() && !h.contains('*')));
                assert!(matches!(
                    delivery,
                    [ProxyDelivery::EnvFile {
                        commands: [EnvCommand {
                            name: "opencode",
                            resolve_guard: None,
                            loopback_direct: true,
                        }],
                        defaults: [(WEBSOCKETS_ENV, "false")],
                    }]
                ));
            }
            other => panic!("expected ProxyEnv, got {other:?}"),
        }
    }

    /// AGT-6 C-5: the three new exact hosts are appended after AGT-5's five, which keep their
    /// order (the probe host stays first).
    #[test]
    fn the_exact_hosts_are_agt5_then_c5() {
        assert_eq!(
            OPENCODE_INTERCEPT_HOSTS,
            &[
                "opencode.ai",
                "api.anthropic.com",
                "api.openai.com",
                "generativelanguage.googleapis.com",
                "openrouter.ai",
                "api.githubcopilot.com",
                "api.groq.com",
                "aiplatform.googleapis.com",
            ]
        );
    }

    /// AGT-6 C-5 (frozen): OpenCode alone declares the six one-label patterns, each a pattern and
    /// none able to match a wildcard, an empty label or a second label.
    #[test]
    fn opencode_declares_the_six_c5_patterns() {
        use crate::model_relay::host_match::matches;
        let b = binding();
        assert_eq!(b.intercept_patterns(), OPENCODE_INTERCEPT_PATTERNS);
        assert_eq!(OPENCODE_INTERCEPT_PATTERNS.len(), 6);
        assert!(OPENCODE_INTERCEPT_PATTERNS.iter().all(HostRule::is_pattern));
        for host in [
            "bedrock-runtime.us-east-1.amazonaws.com",
            "bedrock-runtime.cn-north-1.amazonaws.com.cn",
            "my-resource.openai.azure.com",
            "my-resource.cognitiveservices.azure.com",
            "my-project.services.ai.azure.com",
            "us-central1-aiplatform.googleapis.com",
        ] {
            assert!(matches(OPENCODE_INTERCEPT_PATTERNS, host), "{host}");
        }
        for host in [
            "aiplatform.googleapis.com", // exact, not a pattern
            "bedrock-runtime.amazonaws.com",
            "bedrock-runtime.a.b.amazonaws.com",
            "bedrock-mantle.us-east-1.api.aws",
            "*.openai.azure.com",
            "openai.azure.com",
            "api.groq.com",
        ] {
            assert!(!matches(OPENCODE_INTERCEPT_PATTERNS, host), "{host}");
        }
    }
}