Skip to main content

interlink/
agent.rs

1//! The per-agent channel server and its decision logic.
2//!
3//! Inbound flow, for each message drained from the bus:
4//!   verify signature → addressed to me? → sender on the allowlist? → fresh? →
5//!   not a replay? → dispatch.
6//!
7//! An admitted peer is handled **inline**: the message is pushed straight into
8//! the session as a `<channel>` event. A non-peer may only *knock* to pair;
9//! anything else from a non-peer is dropped at the gate.
10
11use std::collections::{HashMap, VecDeque};
12
13use crate::identity::{AgentId, MessageKind, SignedMessage, TaskStatus, check_freshness};
14use crate::policy::Policy;
15
16/// How far ahead of local time a message may be dated — a tight clock-skew bound; a
17/// message from the future is a forgery/replay signal.
18pub const MAX_FUTURE_MS: u64 = 60_000;
19
20/// How long after signing a message may still be delivered. Generous, because the
21/// bus is a durable keep-until-ack store: an offline or slow peer (or a Claude Code
22/// stdio-server restart gap) can leave a legitimate message queued for a long time,
23/// and it must not be rejected as stale when it finally lands. The replay this widens
24/// is bounded in practice by the dedupe set and the trusted-tailnet threat model.
25pub const MAX_PAST_MS: u64 = 86_400_000; // 24h
26
27/// What to do with a verified, authorized message.
28#[derive(Debug, PartialEq, Eq)]
29pub enum Dispatch {
30    /// Trusted peer: push the full content into the session now. Task-tracking
31    /// metadata (if any) rides along so the session can branch on it — e.g. a
32    /// `NeedsInput` is surfaced to the operator, a terminal status closes the loop.
33    Inline {
34        petname: String,
35        text: String,
36        task_id: Option<String>,
37        status: Option<TaskStatus>,
38        in_reply_to: Option<String>,
39    },
40    /// A non-peer *knocked*: it wants to pair. Carries only its key and a
41    /// self-claimed name — never actionable text. Surfaced for human accept/reject.
42    PairRequest { from_key: String, name: String },
43    /// A peer replied that it accepted our earlier knock. The handler adds it
44    /// only if we actually have an outstanding request to that key.
45    PairAccept { from_key: String, name: String },
46}
47
48/// Why a message was dropped. All of these are logged, none reach the model.
49#[derive(Debug, PartialEq, Eq)]
50pub enum Reject {
51    BadSignature,
52    NotAllowlisted,
53    WrongRecipient,
54    Stale,
55    Replay,
56    /// A pairing kind from someone already a peer, or an otherwise nonsensical
57    /// (peer, kind) combination — ignored.
58    Unexpected,
59}
60
61pub type Verdict = Result<Dispatch, Reject>;
62
63/// The full inbound gate. `me` is this agent's own id; the bus routes by key,
64/// but we re-check so a misrouted or spoofed `to` can't slip through.
65pub fn decide(
66    msg: &SignedMessage,
67    me: AgentId,
68    policy: &Policy,
69    now: u64,
70    seen: &mut Dedupe,
71) -> Verdict {
72    // 1. Authenticate the sender from the signature — never from the `from`
73    //    string, which is attacker-controlled until verified.
74    let from = msg.verify().map_err(|_| Reject::BadSignature)?;
75
76    // 2. Is it actually addressed to us?
77    let to = AgentId::from_b64(&msg.to).map_err(|_| Reject::WrongRecipient)?;
78    if to != me {
79        return Err(Reject::WrongRecipient);
80    }
81
82    // 3. Allowlist. A non-peer may deliver *only* a pairing knock; a plain
83    //    message from one is dropped here, before it can even consume a dedupe
84    //    slot (so non-peers can't flood the replay set). One implicit exception:
85    //    a message from our *own* key is another live session on this same node —
86    //    same principal, so it's trusted without a `peers.json` entry. Only the
87    //    holder of our secret key can produce such a signature, so this grants
88    //    nothing to anyone else.
89    let peer = policy.peer(from);
90    let is_self = from == me;
91    if peer.is_none() && !is_self && msg.kind == MessageKind::Message {
92        return Err(Reject::NotAllowlisted);
93    }
94
95    // 4. Plausible timestamp: tight in the future (skew), generous in the past (the
96    //    durable bus may hold a legitimate message for a while before it's delivered).
97    check_freshness(msg.ts, now, MAX_FUTURE_MS, MAX_PAST_MS).map_err(|_| Reject::Stale)?;
98
99    // 5. Not already seen. Do this after the cheap rejects so a replayed *valid*
100    //    message is recorded only once and invalid ones never consume a slot.
101    if !seen.insert(&msg.msg_id) {
102        return Err(Reject::Replay);
103    }
104
105    match (peer, msg.kind) {
106        (Some(peer), MessageKind::Message) => Ok(Dispatch::Inline {
107            petname: peer.petname.clone(),
108            text: msg.text.clone(),
109            task_id: msg.task_id.clone(),
110            status: msg.status,
111            in_reply_to: msg.in_reply_to.clone(),
112        }),
113        // Another session under our own identity (see is_self, above).
114        (None, MessageKind::Message) if is_self => Ok(Dispatch::Inline {
115            petname: "self".to_string(),
116            text: msg.text.clone(),
117            task_id: msg.task_id.clone(),
118            status: msg.status,
119            in_reply_to: msg.in_reply_to.clone(),
120        }),
121        // A non-peer knock: identity + self-claimed name only.
122        (None, MessageKind::PairRequest) => Ok(Dispatch::PairRequest {
123            from_key: from.to_b64(),
124            name: msg.text.clone(),
125        }),
126        // A crash can occur after persisting the peer but before delivering its
127        // acceptance notice. The handler still requires a matching pending request.
128        (_, MessageKind::PairAccept) => Ok(Dispatch::PairAccept {
129            from_key: from.to_b64(),
130            name: msg.text.clone(),
131        }),
132        // A pairing kind from an existing peer, or any other combination.
133        _ => Err(Reject::Unexpected),
134    }
135}
136
137/// A bounded key→value table (drop-oldest), for pending pairing state: inbound
138/// knocks (sender key → claimed name) and outbound requests (target key → the
139/// grant we'll assign them on accept). Bounded so a knock flood can't grow it.
140pub struct PairTable {
141    order: VecDeque<String>,
142    map: HashMap<String, String>,
143    cap: usize,
144}
145
146impl PairTable {
147    pub fn new(cap: usize) -> Self {
148        Self {
149            order: VecDeque::new(),
150            map: HashMap::new(),
151            cap: cap.max(1),
152        }
153    }
154
155    /// Insert or update `key`; evicts the oldest at capacity.
156    pub fn put(&mut self, key: String, value: String) {
157        if !self.map.contains_key(&key) {
158            if self.order.len() >= self.cap
159                && let Some(old) = self.order.pop_front()
160            {
161                self.map.remove(&old);
162            }
163            self.order.push_back(key.clone());
164        }
165        self.map.insert(key, value);
166    }
167
168    /// Remove and return `key`'s value.
169    pub fn take(&mut self, key: &str) -> Option<String> {
170        let v = self.map.remove(key)?;
171        self.order.retain(|k| k != key);
172        Some(v)
173    }
174
175    /// Resolve a full key or an exact 8-char fingerprint to `(key, value)`.
176    pub fn find(&self, key_or_fp: &str) -> Option<(String, String)> {
177        self.map
178            .iter()
179            .find(|(k, _)| {
180                k.as_str() == key_or_fp || k.chars().take(8).collect::<String>() == key_or_fp
181            })
182            .map(|(k, v)| (k.clone(), v.clone()))
183    }
184
185    pub fn entries(&self) -> impl Iterator<Item = (&String, &String)> {
186        self.map.iter()
187    }
188
189    pub fn is_empty(&self) -> bool {
190        self.map.is_empty()
191    }
192}
193
194/// A bounded set of recently-seen `msg_id`s. Its main job is collapsing the bus's
195/// at-least-once redelivery (the same message seen twice before it's acked), which is
196/// a near-term window, so a bounded LRU suffices. It is *not* sized to cover the full
197/// [`MAX_PAST_MS`] window: a replay older than this capacity but still within the past
198/// bound could slip through — an accepted tradeoff under the signed, trusted-tailnet
199/// threat model, in exchange for durable delivery to offline/slow peers.
200pub struct Dedupe {
201    order: VecDeque<String>,
202    seen: std::collections::HashSet<String>,
203    cap: usize,
204}
205
206impl Dedupe {
207    pub fn new(cap: usize) -> Self {
208        Self {
209            order: VecDeque::with_capacity(cap),
210            seen: std::collections::HashSet::with_capacity(cap),
211            cap: cap.max(1),
212        }
213    }
214
215    /// Record `id`. Returns `false` if it was already present (a replay).
216    pub fn insert(&mut self, id: &str) -> bool {
217        if self.seen.contains(id) {
218            return false;
219        }
220        if self.order.len() >= self.cap
221            && let Some(old) = self.order.pop_front()
222        {
223            self.seen.remove(&old);
224        }
225        self.order.push_back(id.to_string());
226        self.seen.insert(id.to_string());
227        true
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::identity::AgentKey;
235    use crate::policy::Policy;
236
237    fn policy_for(key: &AgentKey) -> Policy {
238        let raw = format!(r#"{{ "alice": {{ "key": "{}" }} }}"#, key.id().to_b64());
239        Policy::parse(&raw).unwrap()
240    }
241
242    #[test]
243    fn admitted_peer_is_dispatched_inline() {
244        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
245        let policy = policy_for(&alice);
246        let msg = alice.sign(me.id(), "run the deploy", 1_000, "m1");
247        let mut seen = Dedupe::new(16);
248        assert_eq!(
249            decide(&msg, me.id(), &policy, 1_000, &mut seen),
250            Ok(Dispatch::Inline {
251                petname: "alice".into(),
252                text: "run the deploy".into(),
253                task_id: None,
254                status: None,
255                in_reply_to: None,
256            })
257        );
258    }
259
260    #[test]
261    fn stranger_is_rejected_even_with_valid_signature() {
262        let (stranger, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
263        let policy = policy_for(&AgentKey::generate().unwrap()); // allowlists someone else
264        let msg = stranger.sign(me.id(), "hi", 1_000, "m1");
265        let mut seen = Dedupe::new(16);
266        assert_eq!(
267            decide(&msg, me.id(), &policy, 1_000, &mut seen),
268            Err(Reject::NotAllowlisted)
269        );
270    }
271
272    #[test]
273    fn own_key_is_trusted_without_a_peers_entry() {
274        // Another session on the same node signs with our own key. It's admitted
275        // implicitly — no self-entry in peers.json — and shown as "self".
276        let me = AgentKey::generate().unwrap();
277        let policy = Policy::default(); // we are NOT in our own allowlist
278        let msg = me.sign(me.id(), "from my other session", 1_000, "m1");
279        let mut seen = Dedupe::new(16);
280        assert_eq!(
281            decide(&msg, me.id(), &policy, 1_000, &mut seen),
282            Ok(Dispatch::Inline {
283                petname: "self".into(),
284                text: "from my other session".into(),
285                task_id: None,
286                status: None,
287                in_reply_to: None,
288            })
289        );
290    }
291
292    #[test]
293    fn forged_sender_is_bad_signature() {
294        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
295        let policy = policy_for(&alice);
296        // Eve signs but stamps alice's key as `from`.
297        let eve = AgentKey::generate().unwrap();
298        let mut msg = eve.sign(me.id(), "hi", 1_000, "m1");
299        msg.from = alice.id().to_b64();
300        let mut seen = Dedupe::new(16);
301        assert_eq!(
302            decide(&msg, me.id(), &policy, 1_000, &mut seen),
303            Err(Reject::BadSignature)
304        );
305    }
306
307    #[test]
308    fn message_for_someone_else_is_rejected() {
309        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
310        let other = AgentKey::generate().unwrap();
311        let policy = policy_for(&alice);
312        let msg = alice.sign(other.id(), "hi", 1_000, "m1"); // addressed to `other`
313        let mut seen = Dedupe::new(16);
314        assert_eq!(
315            decide(&msg, me.id(), &policy, 1_000, &mut seen),
316            Err(Reject::WrongRecipient)
317        );
318    }
319
320    #[test]
321    fn stale_message_is_rejected() {
322        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
323        let policy = policy_for(&alice);
324        let msg = alice.sign(me.id(), "hi", 1_000, "m1");
325        let mut seen = Dedupe::new(16);
326        // Past the generous past bound → stale.
327        let long_after = 1_000 + MAX_PAST_MS + 1;
328        assert_eq!(
329            decide(&msg, me.id(), &policy, long_after, &mut seen),
330            Err(Reject::Stale)
331        );
332        // A future-dated message is rejected by the tight future bound.
333        let future_msg = alice.sign(me.id(), "hi", 1_000 + MAX_FUTURE_MS + 1, "m2");
334        assert_eq!(
335            decide(&future_msg, me.id(), &policy, 1_000, &mut seen),
336            Err(Reject::Stale)
337        );
338        // A message delivered a few minutes late (well within the past bound) is fine.
339        let ok_msg = alice.sign(me.id(), "hi", 1_000, "m3");
340        assert!(decide(&ok_msg, me.id(), &policy, 1_000 + 300_000, &mut seen).is_ok());
341    }
342
343    #[test]
344    fn replay_is_rejected_the_second_time() {
345        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
346        let policy = policy_for(&alice);
347        let msg = alice.sign(me.id(), "hi", 1_000, "m1");
348        let mut seen = Dedupe::new(16);
349        assert!(decide(&msg, me.id(), &policy, 1_000, &mut seen).is_ok());
350        assert_eq!(
351            decide(&msg, me.id(), &policy, 1_000, &mut seen),
352            Err(Reject::Replay)
353        );
354    }
355
356    #[test]
357    fn dedupe_forgets_oldest_beyond_cap() {
358        let mut d = Dedupe::new(2);
359        assert!(d.insert("a"));
360        assert!(d.insert("b"));
361        assert!(d.insert("c")); // evicts "a"
362        assert!(d.insert("a"), "a was evicted, so it is fresh again");
363        assert!(!d.insert("c"), "c is still within the window");
364    }
365
366    #[test]
367    fn non_peer_knock_is_surfaced_not_dropped() {
368        let (stranger, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
369        let policy = Policy::default(); // nobody is a peer
370        let msg = stranger.sign_as(
371            me.id(),
372            "stranger-laptop",
373            1_000,
374            "k1",
375            MessageKind::PairRequest,
376        );
377        let mut seen = Dedupe::new(16);
378        assert_eq!(
379            decide(&msg, me.id(), &policy, 1_000, &mut seen),
380            Ok(Dispatch::PairRequest {
381                from_key: stranger.id().to_b64(),
382                name: "stranger-laptop".into()
383            })
384        );
385    }
386
387    #[test]
388    fn non_peer_plain_message_is_still_denied() {
389        let (stranger, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
390        let policy = Policy::default();
391        let msg = stranger.sign(me.id(), "hi", 1_000, "m1");
392        let mut seen = Dedupe::new(16);
393        assert_eq!(
394            decide(&msg, me.id(), &policy, 1_000, &mut seen),
395            Err(Reject::NotAllowlisted)
396        );
397    }
398
399    #[test]
400    fn pairing_kind_from_existing_peer_is_unexpected() {
401        let (alice, me) = (AgentKey::generate().unwrap(), AgentKey::generate().unwrap());
402        let policy = policy_for(&alice);
403        let msg = alice.sign_as(me.id(), "x", 1_000, "k1", MessageKind::PairRequest);
404        let mut seen = Dedupe::new(16);
405        assert_eq!(
406            decide(&msg, me.id(), &policy, 1_000, &mut seen),
407            Err(Reject::Unexpected)
408        );
409    }
410
411    #[test]
412    fn pair_table_put_take_and_find() {
413        let mut t = PairTable::new(8);
414        t.put("aaaabbbbcccc".into(), "desktop".into());
415        assert_eq!(
416            t.find("aaaabbbbcccc"), // exact full key
417            Some(("aaaabbbbcccc".into(), "desktop".into()))
418        );
419        assert_eq!(
420            t.find("aaaabbbb"), // exact 8-char fingerprint
421            Some(("aaaabbbbcccc".into(), "desktop".into()))
422        );
423        assert_eq!(t.take("aaaabbbbcccc"), Some("desktop".into()));
424        assert!(t.is_empty());
425    }
426}