Skip to main content

nexo_core/agent/
approval_correlator.rs

1//! Approval correlator for chat-driven operator
2//! approval of pending mutations.
3//!
4//! The leak's `ConfigTool` (`claude-code-leak/src/tools/ConfigTool/
5//! ConfigTool.ts:103-106`) leans on a host `'ask'` permission prompt:
6//! the user sees a modal in the CLI / IDE and clicks Allow / Deny.
7//! Over chat-based pairing, there is no modal — the
8//! approval has to be a regular message on the same channel that
9//! originated the proposal.
10//!
11//! This module owns:
12//!   * [`parse_approval_command`] — strict regex parser that lifts
13//!     `[config-approve patch_id=...]` and
14//!     `[config-reject patch_id=... reason=...]` out of a chat
15//!     message body.
16//!   * [`ApprovalCorrelator`] — `DashMap<patch_id, PendingEntry>`
17//!     plus a 24 h reaper (`tokio::spawn`) so the apply path can
18//!     `await` the operator's decision via `oneshot`.
19//!   * [`ApprovalSource`] trait — production `BrokerApprovalSource`
20//!     subscribes to `pairing.inbound.>`; tests use
21//!     [`MockApprovalSource`] which exposes `inject(msg)` for
22//!     deterministic flows.
23//!
24//! The same correlator is the planned anchor for
25//! `ExitPlanMode { wait: true }`, so the API is intentionally generic ("approval
26//! command" with `kind: approve | reject`); only the regex is
27//! ConfigTool-specific in this commit.
28
29use async_trait::async_trait;
30use dashmap::DashMap;
31use std::sync::Arc;
32use std::time::Duration;
33use tokio::sync::oneshot;
34use tokio_util::sync::CancellationToken;
35
36/// Inbound chat message the correlator inspects. Production
37/// `BrokerApprovalSource` builds this from the pairing inbound
38/// stream; tests construct it directly.
39#[derive(Debug, Clone)]
40pub struct InboundApprovalMessage {
41    /// Plugin id (e.g. `whatsapp`).
42    pub channel: String,
43    /// Plugin instance id (e.g. `default`). Combined with `channel`
44    /// to form the binding's `(channel, account_id)` tuple.
45    pub account_id: String,
46    /// Operator's id within the plugin (phone number, chat user id,
47    /// email address). Carried for audit; not used in match logic.
48    pub sender_id: String,
49    pub body: String,
50    pub received_at: i64,
51}
52
53/// Parsed shape of a `[config-approve ...]` / `[config-reject ...]`
54/// block. Kept minimal — `patch_id` is the only mandatory field.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ApprovalCommand {
57    Approve {
58        patch_id: String,
59    },
60    Reject {
61        patch_id: String,
62        reason: Option<String>,
63    },
64}
65
66impl ApprovalCommand {
67    pub fn patch_id(&self) -> &str {
68        match self {
69            ApprovalCommand::Approve { patch_id } | ApprovalCommand::Reject { patch_id, .. } => {
70                patch_id.as_str()
71            }
72        }
73    }
74}
75
76/// Decision delivered back to the apply path.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum ApprovalDecision {
79    Approved,
80    Rejected { reason: Option<String> },
81    Expired,
82}
83
84/// Pending entry while the apply path awaits the operator.
85/// `(channel, account_id)` is the cross-binding-forgery guard:
86/// only messages from the originating binding can resolve the
87/// pending entry.
88pub struct PendingApproval {
89    pub patch_id: String,
90    pub binding_id: String,
91    pub agent_id: String,
92    pub channel: String,
93    pub account_id: String,
94    pub sender_id: String,
95    pub created_at: i64,
96    pub expires_at: i64,
97}
98
99struct PendingEntry {
100    patch: PendingApproval,
101    responder: oneshot::Sender<ApprovalDecision>,
102}
103
104#[derive(Debug, Clone)]
105pub struct ApprovalCorrelatorConfig {
106    pub default_timeout: Duration,
107    pub reaper_interval: Duration,
108}
109
110impl Default for ApprovalCorrelatorConfig {
111    fn default() -> Self {
112        Self {
113            default_timeout: Duration::from_secs(86_400),
114            reaper_interval: Duration::from_secs(60),
115        }
116    }
117}
118
119/// Production correlator. Constructed once at boot, registered with
120/// a single [`ApprovalSource`], shared across every `ConfigTool`
121/// instance.
122pub struct ApprovalCorrelator {
123    pending: DashMap<String, PendingEntry>,
124    config: ApprovalCorrelatorConfig,
125    cancel: CancellationToken,
126}
127
128impl ApprovalCorrelator {
129    pub fn new(config: ApprovalCorrelatorConfig) -> Arc<Self> {
130        Arc::new(Self {
131            pending: DashMap::new(),
132            config,
133            cancel: CancellationToken::new(),
134        })
135    }
136
137    /// Park a fresh proposal. Returns the receiver the apply path
138    /// awaits. The caller MUST consume the receiver exactly once;
139    /// dropping it without awaiting drops the entry on the next
140    /// inbound or reaper sweep.
141    pub fn park(&self, patch: PendingApproval) -> oneshot::Receiver<ApprovalDecision> {
142        let (tx, rx) = oneshot::channel();
143        self.pending.insert(
144            patch.patch_id.clone(),
145            PendingEntry {
146                patch,
147                responder: tx,
148            },
149        );
150        rx
151    }
152
153    /// Number of pending entries (for diagnostics + tests).
154    pub fn pending_count(&self) -> usize {
155        self.pending.len()
156    }
157
158    /// Drop a pending entry explicitly (best-effort). Used by
159    /// ConfigTool cleanup paths when proposal staging fails after a
160    /// `park()` already reserved the patch id.
161    pub fn cancel_patch(&self, patch_id: &str) -> bool {
162        self.pending.remove(patch_id).is_some()
163    }
164
165    /// Spawn the inbound subscriber + reaper tasks. `source` is
166    /// borrowed exclusively by this correlator; multiple correlator
167    /// instances need distinct sources.
168    pub fn spawn_workers(self: &Arc<Self>, source: Arc<dyn ApprovalSource>) {
169        // Inbound subscriber.
170        let me = Arc::clone(self);
171        let cancel = self.cancel.clone();
172        let src = Arc::clone(&source);
173        tokio::spawn(async move {
174            loop {
175                tokio::select! {
176                    _ = cancel.cancelled() => break,
177                    msg = src.next_message() => match msg {
178                        Some(m) => me.on_inbound(m),
179                        None => break,
180                    }
181                }
182            }
183        });
184
185        // Reaper.
186        let me = Arc::clone(self);
187        let cancel = self.cancel.clone();
188        let interval = self.config.reaper_interval;
189        tokio::spawn(async move {
190            loop {
191                tokio::select! {
192                    _ = cancel.cancelled() => break,
193                    _ = tokio::time::sleep(interval) => {
194                        me.reap_expired();
195                    }
196                }
197            }
198        });
199    }
200
201    /// Inject an inbound message synchronously (used by tests +
202    /// the broker subscriber loop).
203    pub fn on_inbound(&self, msg: InboundApprovalMessage) {
204        let cmd = match parse_approval_command(&msg.body) {
205            Some(c) => c,
206            None => return,
207        };
208        let patch_id = cmd.patch_id().to_string();
209        // Take the entry so a forgery attempt cannot resolve it
210        // *and* leave it dangling.
211        let Some((_, entry)) = self.pending.remove(&patch_id) else {
212            tracing::debug!(
213                target: "config::approval",
214                patch_id = %patch_id,
215                "[config] inbound matched no pending entry"
216            );
217            return;
218        };
219        if entry.patch.channel != msg.channel || entry.patch.account_id != msg.account_id {
220            tracing::warn!(
221                target: "config::approval_forgery_rejected",
222                patch_id = %patch_id,
223                expected_channel = %entry.patch.channel,
224                expected_account = %entry.patch.account_id,
225                got_channel = %msg.channel,
226                got_account = %msg.account_id,
227                "[config] approval came from wrong binding — discarded"
228            );
229            // Re-insert the entry; the originator may still approve.
230            self.pending.insert(entry.patch.patch_id.clone(), entry);
231            return;
232        }
233        let decision = match cmd {
234            ApprovalCommand::Approve { .. } => ApprovalDecision::Approved,
235            ApprovalCommand::Reject { reason, .. } => ApprovalDecision::Rejected { reason },
236        };
237        let _ = entry.responder.send(decision);
238    }
239
240    fn reap_expired(&self) {
241        let now = chrono::Utc::now().timestamp();
242        let mut to_drop: Vec<String> = Vec::new();
243        for kv in self.pending.iter() {
244            if kv.value().patch.expires_at <= now {
245                to_drop.push(kv.key().clone());
246            }
247        }
248        for id in to_drop {
249            if let Some((_, entry)) = self.pending.remove(&id) {
250                tracing::info!(
251                    target: "config::approval_expired",
252                    patch_id = %id,
253                    "[config] approval expired"
254                );
255                let _ = entry.responder.send(ApprovalDecision::Expired);
256            }
257        }
258    }
259
260    /// Cancel both background tasks. Best-effort — pending
261    /// receivers see the responder dropped (returns `Err(_)`).
262    pub fn shutdown(&self) {
263        self.cancel.cancel();
264    }
265}
266
267/// Subscription source for inbound messages. Production wiring
268/// uses `BrokerApprovalSource` (subscribes to `pairing.inbound.>`);
269/// tests use [`MockApprovalSource`].
270#[async_trait]
271pub trait ApprovalSource: Send + Sync {
272    /// Next inbound message; `None` when the source is closed.
273    /// The correlator's spawned subscriber loop pulls one message
274    /// at a time and drives `on_inbound` synchronously.
275    async fn next_message(&self) -> Option<InboundApprovalMessage>;
276}
277
278/// Test-only source. Inject messages via `inject()`; the worker
279/// loop drains them via `next_message()` and drives the correlator.
280pub struct MockApprovalSource {
281    queue: tokio::sync::Mutex<std::collections::VecDeque<InboundApprovalMessage>>,
282    notify: tokio::sync::Notify,
283    closed: std::sync::atomic::AtomicBool,
284}
285
286impl Default for MockApprovalSource {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl MockApprovalSource {
293    pub fn new() -> Self {
294        Self {
295            queue: tokio::sync::Mutex::new(std::collections::VecDeque::new()),
296            notify: tokio::sync::Notify::new(),
297            closed: std::sync::atomic::AtomicBool::new(false),
298        }
299    }
300
301    pub async fn inject(&self, msg: InboundApprovalMessage) {
302        self.queue.lock().await.push_back(msg);
303        self.notify.notify_one();
304    }
305
306    pub fn close(&self) {
307        self.closed
308            .store(true, std::sync::atomic::Ordering::Relaxed);
309        self.notify.notify_waiters();
310    }
311}
312
313#[async_trait]
314impl ApprovalSource for MockApprovalSource {
315    async fn next_message(&self) -> Option<InboundApprovalMessage> {
316        loop {
317            if let Some(m) = self.queue.lock().await.pop_front() {
318                return Some(m);
319            }
320            if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
321                return None;
322            }
323            self.notify.notified().await;
324        }
325    }
326}
327
328/// Strict regex match for the approval message format. Anchored
329/// to the whole trimmed body so `xx [config-approve ...] yy` does
330/// NOT match — operators must send the bracketed command alone
331/// (or with surrounding whitespace).
332///
333/// Grammar:
334///   `^\s*\[config-(approve|reject)\s+patch_id=<ULID>(?:\s+reason=<text>)?\]\s*$`
335///
336/// `<ULID>` is a Crockford-base32 char class
337/// `[0-9A-HJKMNP-TV-Z]+` (case-sensitive, matching `uuid`-7 ULIDs).
338pub fn parse_approval_command(body: &str) -> Option<ApprovalCommand> {
339    use std::sync::OnceLock;
340    static RE: OnceLock<regex::Regex> = OnceLock::new();
341    let re = RE.get_or_init(|| {
342        regex::Regex::new(
343            r"(?x)
344              ^\s*
345              \[
346                config-
347                (?P<verb>approve|reject)
348                \s+
349                patch_id=
350                (?P<id>[0-9A-HJKMNP-TV-Z]+)
351                (?:\s+reason=(?P<reason>.*?))?
352              \]
353              \s*$
354            ",
355        )
356        .expect("approval-command regex must compile")
357    });
358    let caps = re.captures(body.trim())?;
359    let verb = caps.name("verb")?.as_str();
360    let id = caps.name("id")?.as_str().to_string();
361    match verb {
362        "approve" => Some(ApprovalCommand::Approve { patch_id: id }),
363        "reject" => {
364            let reason = caps.name("reason").map(|m| m.as_str().trim().to_string());
365            Some(ApprovalCommand::Reject {
366                patch_id: id,
367                reason,
368            })
369        }
370        _ => None,
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::sync::atomic::Ordering;
378
379    fn fixture_pending(patch_id: &str) -> PendingApproval {
380        PendingApproval {
381            patch_id: patch_id.into(),
382            binding_id: "whatsapp:default".into(),
383            agent_id: "cody".into(),
384            channel: "whatsapp".into(),
385            account_id: "default".into(),
386            sender_id: "5511".into(),
387            created_at: 0,
388            expires_at: i64::MAX,
389        }
390    }
391
392    fn fixture_inbound(_patch_id: &str, body: &str) -> InboundApprovalMessage {
393        InboundApprovalMessage {
394            channel: "whatsapp".into(),
395            account_id: "default".into(),
396            sender_id: "5511".into(),
397            body: body.into(),
398            received_at: 0,
399        }
400    }
401
402    #[test]
403    fn parse_approve_minimal() {
404        let cmd = parse_approval_command("[config-approve patch_id=01J7HVK9MWXYZ]").unwrap();
405        assert_eq!(
406            cmd,
407            ApprovalCommand::Approve {
408                patch_id: "01J7HVK9MWXYZ".into()
409            }
410        );
411    }
412
413    #[test]
414    fn parse_reject_with_reason() {
415        let cmd = parse_approval_command(
416            "[config-reject patch_id=01J7HVK9MWXYZ reason=use sonnet por costo]",
417        )
418        .unwrap();
419        assert_eq!(
420            cmd,
421            ApprovalCommand::Reject {
422                patch_id: "01J7HVK9MWXYZ".into(),
423                reason: Some("use sonnet por costo".into())
424            }
425        );
426    }
427
428    #[test]
429    fn parse_reject_without_reason() {
430        let cmd = parse_approval_command("[config-reject patch_id=01J7HVK9MWXYZ]").unwrap();
431        assert_eq!(
432            cmd,
433            ApprovalCommand::Reject {
434                patch_id: "01J7HVK9MWXYZ".into(),
435                reason: None
436            }
437        );
438    }
439
440    #[test]
441    fn parse_ignores_garbage() {
442        assert!(parse_approval_command("hello world").is_none());
443        assert!(parse_approval_command("").is_none());
444        assert!(parse_approval_command("[config-approve]").is_none());
445        assert!(parse_approval_command("[config-approve patch_id=]").is_none());
446        // Patch id with lower-case is rejected (Crockford is upper).
447        assert!(parse_approval_command("[config-approve patch_id=foo]").is_none());
448    }
449
450    #[test]
451    fn parse_strict_anchors_full_message() {
452        // Embedded inside a sentence — must NOT match.
453        assert!(
454            parse_approval_command("let's go [config-approve patch_id=01J7HVK9MWXYZ] thanks")
455                .is_none()
456        );
457    }
458
459    #[tokio::test]
460    async fn correlator_resolves_pending_on_match() {
461        let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
462        let rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
463        c.on_inbound(fixture_inbound(
464            "01J7HVK9MWXYZ",
465            "[config-approve patch_id=01J7HVK9MWXYZ]",
466        ));
467        let decision = rx.await.unwrap();
468        assert_eq!(decision, ApprovalDecision::Approved);
469        assert_eq!(c.pending_count(), 0);
470    }
471
472    #[tokio::test]
473    async fn correlator_resolves_with_reason_on_reject() {
474        let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
475        let rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
476        c.on_inbound(fixture_inbound(
477            "01J7HVK9MWXYZ",
478            "[config-reject patch_id=01J7HVK9MWXYZ reason=cost]",
479        ));
480        match rx.await.unwrap() {
481            ApprovalDecision::Rejected { reason } => assert_eq!(reason.as_deref(), Some("cost")),
482            other => panic!("expected Rejected, got {other:?}"),
483        }
484    }
485
486    #[tokio::test]
487    async fn correlator_rejects_cross_binding_message() {
488        let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
489        let _rx = c.park(fixture_pending("01J7HVK9MWXYZ"));
490        // Inbound from binding B (telegram:other) — must NOT match.
491        let bad_binding = InboundApprovalMessage {
492            channel: "telegram".into(),
493            account_id: "other".into(),
494            sender_id: "abc".into(),
495            body: "[config-approve patch_id=01J7HVK9MWXYZ]".into(),
496            received_at: 0,
497        };
498        c.on_inbound(bad_binding);
499        // Pending entry must remain.
500        assert_eq!(c.pending_count(), 1);
501    }
502
503    #[tokio::test]
504    async fn correlator_no_pending_entry_logs_and_returns() {
505        let c = ApprovalCorrelator::new(ApprovalCorrelatorConfig::default());
506        c.on_inbound(fixture_inbound(
507            "01J7UNKNOWNPATCHID",
508            "[config-approve patch_id=01J7UNKNOWNPATCHID]",
509        ));
510        assert_eq!(c.pending_count(), 0);
511    }
512
513    #[tokio::test(start_paused = true)]
514    async fn correlator_expires_pending_after_timeout() {
515        let cfg = ApprovalCorrelatorConfig {
516            default_timeout: Duration::from_secs(1),
517            reaper_interval: Duration::from_millis(50),
518        };
519        let c = ApprovalCorrelator::new(cfg);
520        let mut p = fixture_pending("01J7HVK9MWXYZ");
521        p.expires_at = chrono::Utc::now().timestamp() - 1; // already expired
522        let rx = c.park(p);
523        // Run a single reap manually (don't depend on the spawned
524        // worker, which would also work).
525        c.reap_expired();
526        let decision = rx.await.unwrap();
527        assert_eq!(decision, ApprovalDecision::Expired);
528        assert_eq!(c.pending_count(), 0);
529    }
530
531    #[tokio::test]
532    async fn mock_source_round_trips_messages() {
533        let src = Arc::new(MockApprovalSource::new());
534        let inbound = fixture_inbound("01J7HVK9MWXYZ", "[config-approve patch_id=01J7HVK9MWXYZ]");
535        src.inject(inbound.clone()).await;
536        let got = src.next_message().await.unwrap();
537        assert_eq!(got.body, inbound.body);
538        src.close();
539        // Closed → None.
540        assert!(src.next_message().await.is_none());
541        assert!(src.closed.load(Ordering::Relaxed));
542    }
543}