openlatch-client 0.6.14

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
//! The OpenCode session window behind `bypass_suspected` (AGT-5 C-5/C-6, Plan D-9/D-10).
//!
//! Two sides meet here, both keyed by OpenCode's own session id:
//!
//! | Side | Fed from | When |
//! | ---- | -------- | ---- |
//! | relay | a request whose `User-Agent` product token is `opencode/` and that carries a C-6 session header | in the relay handler, **before** attribution and before the request is recorded or suppressed, so a `--pure` run's id is seen even with no live session, or with an unrelated one |
//! | plugin | every OpenCode plugin event that names a session | in the hook handler, live events only |
//!
//! A session the relay saw and the plugin never reported is a suspected bypass: OpenCode ran
//! without our plugin (`--pure`, or a load failure). Kept **daemon-side only and in memory**: the
//! `User-Agent` and the session id never reach the relay record, so no schema changes, and the
//! window does not survive a daemon restart — doctor says "since daemon start" while the daemon
//! is younger than the window.
//!
//! A grace period separates the two sides: OpenCode's prompt hook decides before the model call
//! it gates, so the plugin side normally lands first, and the grace covers the capture-only
//! events that race it. Each suspected session is reported once ([`take_new_suspects`]).
//!
//! [`take_new_suspects`]: OpencodeSessionWindow::take_new_suspects

use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::time::{Duration, Instant};

/// How long a session id stays in the window (C-5: "in the last 24 hours").
pub const WINDOW: Duration = Duration::from_secs(24 * 60 * 60);

/// How long a relay-side id waits for its plugin report before it is suspected.
pub const GRACE: Duration = Duration::from_secs(10);

/// The most ids each side holds; the oldest is evicted first. Bounds memory on a host that runs
/// OpenCode all day.
pub const MAX_IDS: usize = 4096;

/// The product token OpenCode puts first in its `User-Agent` (`opencode/<version> ai-sdk/…`).
pub const USER_AGENT_TOKEN: &str = "opencode/";

/// Whether a `User-Agent` value is OpenCode's own (its first product token).
pub fn is_opencode_user_agent(user_agent: &str) -> bool {
    user_agent
        .trim_start()
        .get(..USER_AGENT_TOKEN.len())
        .is_some_and(|p| p.eq_ignore_ascii_case(USER_AGENT_TOKEN))
}

#[derive(Clone, Copy)]
struct RelaySeen {
    first: Instant,
    last: Instant,
}

#[derive(Default)]
struct Inner {
    /// Session id → when the relay first and last saw OpenCode traffic for it. The first sighting
    /// starts the grace; the last keeps an active session in the rolling window.
    relay: HashMap<String, RelaySeen>,
    /// Session id → when the plugin last reported it.
    plugin: HashMap<String, Instant>,
    /// Suspected sessions already reported (tamper event emitted).
    reported: HashSet<String>,
    /// The last OpenCode request the relay saw, session header or not.
    last_traffic: Option<Instant>,
    /// The same moment on the wall clock, in epoch milliseconds, for doctor to compare with the
    /// plugin file's write time.
    last_traffic_ms: Option<i64>,
}

/// The window. One per daemon, shared by the relay listeners and the hook handler.
pub struct OpencodeSessionWindow {
    inner: Mutex<Inner>,
    started: Instant,
}

impl Default for OpencodeSessionWindow {
    fn default() -> Self {
        Self::new(Instant::now())
    }
}

/// What doctor reads, through the relay's status endpoint.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowSnapshot {
    /// The relay has seen OpenCode model traffic in the window.
    pub traffic: bool,
    /// When it last did, in epoch milliseconds.
    pub last_traffic_ms: Option<i64>,
    /// Sessions the relay saw that the plugin never reported, past the grace.
    pub suspected: usize,
    /// Whether the window is shorter than [`WINDOW`] because the daemon started inside it.
    pub since_daemon_start: bool,
}

impl WindowSnapshot {
    /// The `opencode` object of `GET /admin/model-relay/status`.
    pub fn to_json(&self) -> serde_json::Value {
        serde_json::json!({
            "traffic": self.traffic,
            "last_traffic_ms": self.last_traffic_ms,
            "bypass_suspected": self.suspected > 0,
            "suspected_sessions": self.suspected,
            "since_daemon_start": self.since_daemon_start,
        })
    }
}

fn lock(m: &Mutex<Inner>) -> std::sync::MutexGuard<'_, Inner> {
    m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// Insert `id`, or update it in place, evicting the entry `age` ranks oldest once `map` holds
/// [`MAX_IDS`].
fn bounded_upsert<V: Copy>(
    map: &mut HashMap<String, V>,
    id: &str,
    new: V,
    update: impl FnOnce(&mut V),
    age: impl Fn(&V) -> Instant,
) {
    if let Some(seen) = map.get_mut(id) {
        update(seen);
        return;
    }
    if map.len() >= MAX_IDS {
        if let Some(oldest) = map
            .iter()
            .min_by_key(|(_, v)| age(v))
            .map(|(k, _)| k.clone())
        {
            map.remove(&oldest);
        }
    }
    map.insert(id.to_string(), new);
}

impl OpencodeSessionWindow {
    /// A window whose daemon started at `started`.
    pub fn new(started: Instant) -> Self {
        Self {
            inner: Mutex::new(Inner::default()),
            started,
        }
    }

    /// The relay side: one OpenCode model turn naming `session_id` (the first C-6 header's
    /// value). A blank id still counts as traffic, never as a session.
    pub fn note_relay(&self, session_id: &str, now: Instant) {
        let mut inner = lock(&self.inner);
        inner.last_traffic = Some(now);
        inner.last_traffic_ms = Some(chrono::Utc::now().timestamp_millis());
        let id = session_id.trim();
        if !id.is_empty() {
            bounded_upsert(
                &mut inner.relay,
                id,
                RelaySeen {
                    first: now,
                    last: now,
                },
                |seen| seen.last = now,
                |seen| seen.last,
            );
        }
    }

    /// The plugin side: one OpenCode plugin event naming `session_id`.
    pub fn note_plugin(&self, session_id: &str, now: Instant) {
        let id = session_id.trim();
        if id.is_empty() {
            return;
        }
        bounded_upsert(
            &mut lock(&self.inner).plugin,
            id,
            now,
            |seen| *seen = now,
            |seen| *seen,
        );
    }

    fn prune(inner: &mut Inner, now: Instant) {
        let live = |t: &Instant| now.saturating_duration_since(*t) <= WINDOW;
        // An active session stays: the window rolls on its last sighting, so a session that runs
        // across the 24-hour mark is neither dropped nor reported a second time (Plan D-9, D-10).
        inner.relay.retain(|_, seen| live(&seen.last));
        inner.plugin.retain(|_, t| live(t));
        let relay = &inner.relay;
        inner.reported.retain(|id| relay.contains_key(id));
        if inner.last_traffic.is_some_and(|t| !live(&t)) {
            inner.last_traffic = None;
            inner.last_traffic_ms = None;
        }
    }

    fn suspected_in(inner: &Inner, now: Instant) -> Vec<String> {
        let mut out: Vec<String> = inner
            .relay
            .iter()
            .filter(|(id, seen)| {
                now.saturating_duration_since(seen.first) >= GRACE
                    && !inner.plugin.contains_key(*id)
            })
            .map(|(id, _)| id.clone())
            .collect();
        out.sort();
        out
    }

    /// Suspected sessions not reported yet; each is marked reported, so it is returned once.
    pub fn take_new_suspects(&self, now: Instant) -> Vec<String> {
        let mut inner = lock(&self.inner);
        Self::prune(&mut inner, now);
        let fresh: Vec<String> = Self::suspected_in(&inner, now)
            .into_iter()
            .filter(|id| !inner.reported.contains(id))
            .collect();
        inner.reported.extend(fresh.iter().cloned());
        fresh
    }

    /// Return sessions [`Self::take_new_suspects`] handed out whose report could not be delivered,
    /// so the next sweep hands them out again. A session that has left the window stays gone.
    pub fn requeue(&self, sessions: &[String]) {
        let mut inner = lock(&self.inner);
        for id in sessions {
            inner.reported.remove(id);
        }
    }

    /// The state doctor reads.
    pub fn snapshot(&self, now: Instant) -> WindowSnapshot {
        let mut inner = lock(&self.inner);
        Self::prune(&mut inner, now);
        WindowSnapshot {
            traffic: inner.last_traffic.is_some(),
            last_traffic_ms: inner.last_traffic_ms,
            suspected: Self::suspected_in(&inner, now).len(),
            since_daemon_start: now.saturating_duration_since(self.started) < WINDOW,
        }
    }
}

/// The `tamper_detected` event for one suspected session (Plan D-10): a host fact on
/// `hook_entry`, never healable, deduplicated by the window. The session id rides in `entry_id`,
/// as a hook registration's id does: the event carries no subject.
pub fn bypass_event(session_id: &str) -> crate::cloud::tamper::TamperEvent {
    use crate::cloud::tamper::{TamperEvent, DETECTION_PLUGIN_BYPASS_SUSPECTED};
    TamperEvent::new(
        format!("opencode:session:{session_id}"),
        crate::hooks::bindings::opencode::OpencodeBinding::AGENT_TYPE.to_string(),
        String::new(),
        "plugin_loaded".to_string(),
        DETECTION_PLUGIN_BYPASS_SUSPECTED.to_string(),
    )
    .with_heal("not_healable", 0, "closed")
}

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

    fn at(base: Instant, secs: u64) -> Instant {
        base + Duration::from_secs(secs)
    }

    #[test]
    fn the_user_agent_token_is_the_first_product_only() {
        assert!(is_opencode_user_agent(
            "opencode/1.18.35 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14"
        ));
        assert!(is_opencode_user_agent("OpenCode/1.0"));
        assert!(!is_opencode_user_agent("ai-sdk/4 opencode/1.18.35"));
        assert!(!is_opencode_user_agent("cline/3.0"));
        assert!(!is_opencode_user_agent("opencode"));
        assert!(!is_opencode_user_agent(""));
    }

    /// A `--pure` run: the relay sees the session, the plugin never does. Suspected once the
    /// grace has passed, reported exactly once.
    #[test]
    fn a_session_the_plugin_never_reported_is_suspected_once() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_relay("ses_pure", t0);
        assert!(
            w.take_new_suspects(at(t0, 1)).is_empty(),
            "inside the grace"
        );
        assert_eq!(w.snapshot(at(t0, 1)).suspected, 0);
        assert_eq!(
            w.take_new_suspects(at(t0, 11)),
            vec!["ses_pure".to_string()]
        );
        assert!(w.take_new_suspects(at(t0, 12)).is_empty(), "reported once");
        let snap = w.snapshot(at(t0, 12));
        assert!(snap.traffic);
        assert_eq!(snap.suspected, 1, "still suspected, only not re-reported");
        assert!(snap.since_daemon_start);
    }

    /// A normal run: the plugin's prompt report lands first, or inside the grace.
    #[test]
    fn a_session_the_plugin_reported_is_never_suspected() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_plugin("ses_a", t0);
        w.note_relay("ses_a", at(t0, 1));
        w.note_relay("ses_b", at(t0, 1));
        w.note_plugin("ses_b", at(t0, 5));
        assert!(w.take_new_suspects(at(t0, 60)).is_empty());
        assert_eq!(w.snapshot(at(t0, 60)).suspected, 0);
    }

    /// Plan D-9: the relay side does not depend on any live session — a pure run beside an
    /// unrelated live plugin session is still suspected.
    #[test]
    fn an_unrelated_plugin_session_does_not_hide_a_pure_run() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_plugin("ses_live", t0);
        w.note_relay("ses_pure", t0);
        assert_eq!(
            w.take_new_suspects(at(t0, 30)),
            vec!["ses_pure".to_string()]
        );
    }

    #[test]
    fn traffic_with_a_blank_session_is_traffic_but_no_session() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        assert!(!w.snapshot(t0).traffic);
        w.note_relay("  ", t0);
        let snap = w.snapshot(at(t0, 60));
        assert!(snap.traffic);
        assert_eq!(snap.suspected, 0);
    }

    #[test]
    fn entries_age_out_of_the_window() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_relay("ses_old", t0);
        let later = t0 + WINDOW + Duration::from_secs(1);
        let snap = w.snapshot(later);
        assert!(!snap.traffic);
        assert_eq!(snap.suspected, 0);
        assert!(!snap.since_daemon_start);
    }

    /// Plan D-9/D-10: the window rolls on the last sighting. A `--pure` session still active past
    /// the 24-hour mark keeps `bypass_suspected` and is never reported a second time.
    #[test]
    fn an_active_session_survives_the_window_and_is_reported_once() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_relay("ses_long", t0);
        assert_eq!(
            w.take_new_suspects(at(t0, 11)),
            vec!["ses_long".to_string()]
        );
        let near_end = t0 + WINDOW - Duration::from_secs(60);
        w.note_relay("ses_long", near_end);
        let past = t0 + WINDOW + Duration::from_secs(60);
        assert!(w.take_new_suspects(past).is_empty(), "never twice");
        let snap = w.snapshot(past);
        assert!(snap.traffic);
        assert_eq!(snap.suspected, 1, "still suspected past the 24h mark");
    }

    /// A report that could not be delivered is handed out again, once.
    #[test]
    fn a_requeued_suspect_is_handed_out_again() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        w.note_relay("ses_pure", t0);
        let got = w.take_new_suspects(at(t0, 11));
        assert_eq!(got, vec!["ses_pure".to_string()]);
        w.requeue(&got);
        assert_eq!(w.take_new_suspects(at(t0, 16)), got);
        assert!(w.take_new_suspects(at(t0, 21)).is_empty());
    }

    #[test]
    fn each_side_is_bounded() {
        let t0 = Instant::now();
        let w = OpencodeSessionWindow::new(t0);
        for i in 0..(MAX_IDS + 10) {
            w.note_relay(&format!("ses_{i}"), at(t0, i as u64 % 3));
        }
        assert!(lock(&w.inner).relay.len() <= MAX_IDS);
    }

    #[test]
    fn the_bypass_event_is_a_high_unhealable_hook_entry_fact() {
        let e = bypass_event("ses_x");
        assert_eq!(e.tamper.agent_type, "opencode");
        assert_eq!(
            e.tamper.component,
            crate::cloud::tamper::COMPONENT_HOOK_ENTRY
        );
        assert_eq!(e.tamper.detection_method, "plugin_bypass_suspected");
        assert_eq!(e.ocsf.severity_id, 4);
        assert_eq!(e.tamper.heal.outcome, "not_healable");
        assert_eq!(e.tamper.entry_id, "opencode:session:ses_x");
    }

    #[test]
    fn the_snapshot_json_names_its_fields() {
        let snap = WindowSnapshot {
            traffic: true,
            last_traffic_ms: Some(7),
            suspected: 2,
            since_daemon_start: true,
        };
        assert_eq!(
            snap.to_json(),
            serde_json::json!({
                "traffic": true,
                "last_traffic_ms": 7,
                "bypass_suspected": true,
                "suspected_sessions": 2,
                "since_daemon_start": true,
            })
        );
    }
}