Skip to main content

onlyne_client/session/dispatch/
guards.rs

1//! The client-side constraints a session's tool calls are measured against.
2//!
3//! A `tools` mount carries no policy of its own (`docs/v2-CONTRACT.md` §3b):
4//! the hop budget, the relay requirement, the completion's shape and the
5//! `details` ceiling are enforced here, where the frame is handled, so the pi
6//! drive and the ACP drive refuse identically. The frame's answer is its own
7//! refusal — a tool call is answered as a tool error, not as a state frame that
8//! also refused something — which is why these read a report and return the
9//! `ResBody` the sender is handed.
10//!
11//! Two of the checks are the pi plugin's private guard moved down: the relay
12//! requirement (a session that owes a downstream handoff may not report a
13//! terminal outcome) and the completion's shape. The plugin's own wording is
14//! kept so the sentence a model reads does not change with the drive that
15//! delivered it. There is no waiver: the plugin's `force`/`reason` escape hatch
16//! went with its guard, so a session that cannot make the handoff it owes
17//! reports nothing and is settled by the turn-end rule — which is the honest
18//! outcome, and what to do about it is operator policy (§3c).
19//!
20//! One duty of this module is attribution rather than constraint. A `tools`
21//! mount names nothing session-scoped — the token in its hello is the binding —
22//! so the task a frame names, the sender it leaves as, and its whole causality
23//! chain are *stamped* from this client's own record of the session that token
24//! names. `onlyne mcp` supplies the recipient, the text, the kind, and the
25//! image, and nothing else (`docs/v2-CONTRACT.md` §3b): a caller that named its
26//! own task or role would be asserting facts only this client's books hold.
27
28use super::state::{DispatchInner, DispatchState, slot_key_named, slot_key_serving_task};
29use super::transport::serves_session;
30use super::*;
31use onlyne_proto::adapter::HandoffArgs;
32use onlyne_proto::{DETAILS_MAX_BYTES, ErrorCode, Report, ResBody};
33
34/// The slot key a connection speaks for, live transport or tools mount alike.
35///
36/// The tools binding and the transport map are the two ways a connection is
37/// tied to a session, and a frame's sender is the connection: resolving the
38/// key here is what lets the relay guard read the session's own delivery
39/// record rather than one the frame claims.
40pub(super) fn session_key_of_connection(inner: &DispatchInner, io: &AdapterIo) -> Option<String> {
41    let named = inner
42        .transports
43        .iter()
44        .find(|(_, (transport, _))| transport.same_connection(io))
45        .map(|(session, _)| session.clone())
46        .or_else(|| {
47            inner
48                .tools_mounts
49                .iter()
50                .find(|(_, bound)| bound.same_connection(io))
51                .map(|(key, _)| key.clone())
52        })?;
53    slot_key_named(inner, &named)
54}
55
56/// A `tools` mount's own bookkeeping, stamped from this client's record.
57///
58/// The scope is [`DispatchState::tools_scope`]'s answer — one locked pass over
59/// the connection's binding and the session's slot — and the stamps below are
60/// pure over it, so a frame cannot be attributed from a state that moved between
61/// the lookup and the write.
62impl DispatchState {
63    /// The one sentence a `tools` mount reads when the session its token names is
64    /// gone.
65    ///
66    /// The handshake and the per-frame gate answer it. The adapter's welcome
67    /// refusal carries a code and a message and has no field slot, so the
68    /// sentence is where the field name goes; the per-frame [`Self::tools_gone`]
69    /// names the same field in the slot it has.
70    pub const TOOLS_GONE_MESSAGE: &'static str = "token names no live session";
71
72    /// The refusal a `tools` frame earns when its connection speaks for no live
73    /// session.
74    ///
75    /// The field is named and the token's own value is not: the token is a
76    /// capability the caller already holds, and a sentence that echoed it would
77    /// put one in a fault a model reads (`AGENTS.md` §8). A session retires
78    /// between one frame's liveness check and its stamping, and both doors read
79    /// one answer because it is one fact.
80    pub fn tools_gone() -> ResBody {
81        ResBody::err(
82            ErrorCode::Unauthorized,
83            Self::TOOLS_GONE_MESSAGE,
84            Some("token".to_string()),
85        )
86    }
87
88    /// Stamp one `report` or `handoff` frame from a `tools` mount with the task
89    /// the session serves.
90    ///
91    /// An empty `task_id` is this path's spelling of "the session's own open
92    /// task" and is replaced with it. A non-empty one that disagrees with the
93    /// session's own binding is refused rather than silently overwritten — a
94    /// caller wrong about the session it speaks for is a bug in the caller, and
95    /// a correction that hides it is the wrong answer. A session holding no open
96    /// task answers `invalid` on the same field, because a completion for a task
97    /// nobody holds cannot be recorded.
98    pub fn stamp_tools_task(
99        &self,
100        io: &AdapterIo,
101        task_id: &mut String,
102    ) -> std::result::Result<(), ResBody> {
103        let Some(scope) = self.tools_scope(io) else {
104            return Err(Self::tools_gone());
105        };
106        let Some(open) = scope.task_id else {
107            return Err(ResBody::err(
108                ErrorCode::Invalid,
109                "this session holds no open task",
110                Some("task_id".to_string()),
111            ));
112        };
113        if task_id.is_empty() {
114            *task_id = open;
115            return Ok(());
116        }
117        if *task_id != open {
118            return Err(ResBody::err(
119                ErrorCode::Forbidden,
120                format!("this session serves task {open}, not {task_id}"),
121                Some("task_id".to_string()),
122            ));
123        }
124        Ok(())
125    }
126
127    /// The sender one `send` frame from a `tools` mount leaves as, and the chain
128    /// that frame starts.
129    ///
130    /// The bridge supplies the recipient, the body, the kind, and the image; the
131    /// role the frame leaves as comes from this client's own record, so nothing
132    /// here is read off the frame or off `welcome`. A session serving no
133    /// delivery is refused: a send it made would belong to no work at all.
134    ///
135    /// `handoff` continues a family and `send` starts one, so a `task` send is a
136    /// **root** — a fresh task id at hop 0, attempt 0, with a fresh `op_id` — and
137    /// nothing of the served delivery's family, budget, origin, or deadline rides
138    /// along. The hop budget belongs to the family, which is why a spent budget
139    /// stops a forward and never new work. A reader who "simplifies" this into
140    /// [`Causality::child_of`] would make a new task spend its sender's hop and
141    /// hand the server an envelope that `plugins/onlyne-agent-pi`'s own
142    /// `sendEnvelope` never mints: two drives, one obligation, two shapes. A
143    /// `note` joins no family: no chain and no `op_id`.
144    pub fn stamp_tools_send(
145        &self,
146        io: &AdapterIo,
147        envelope: &mut Envelope,
148    ) -> std::result::Result<(), ResBody> {
149        let Some(scope) = self.tools_scope(io) else {
150            return Err(Self::tools_gone());
151        };
152        if scope.task_id.is_none() {
153            return Err(ResBody::err(
154                ErrorCode::Invalid,
155                "this session serves no delivery, so a send it makes belongs to no work",
156                None,
157            ));
158        }
159        envelope.from = Principal::role(self.inner.lock().role.clone());
160        if envelope.kind == MsgKind::Task {
161            envelope.op_id = Some(onlyne_proto::new_op_id());
162            envelope.causality = Some(Causality::root(onlyne_proto::new_task_id()));
163        } else {
164            envelope.op_id = None;
165            envelope.causality = None;
166        }
167        Ok(())
168    }
169}
170
171/// Record one delivery a session made, which is the relay guard's evidence.
172///
173/// Any envelope kind counts — a `note` and a `task` are both the session
174/// reaching that role — and a recipient that names no role (a gateway, a
175/// cluster) is not a downstream handoff. The caller records only sends the
176/// client has carried: a refused envelope was never a delivery.
177pub(super) fn record_delivery(inner: &mut DispatchInner, key: &str, to: &Principal) {
178    let Some(role) = to.role_name() else {
179        return;
180    };
181    if let Some(slot) = inner.sessions.get_mut(key) {
182        slot.delivered_roles.insert(role.to_string());
183    }
184}
185
186/// The relay guard's refusal, when the session still owes a delivery.
187///
188/// The obligation is the role's own `allowed_targets`: the list the server
189/// gates the ACL on, which the handshake carries (and a reload's role row
190/// re-carries). A session of that role must have delivered to every downstream
191/// name on it before it may report a terminal outcome, and a role that declares
192/// no target owes nothing.
193///
194/// The role this session's task came from is never one of them. The completion
195/// is itself a delivery to that role — the one the ledger books the answer
196/// against — so owing a second one would make the obligation unsatisfiable for
197/// the self-addressed entry `onlyne-client init` prints and about twenty
198/// fixtures restate (`e2e/acp-tools.sh`), and for a ring's return edge
199/// (`e2e/running-lights.sh`). What the exclusion drops is the origin alone, not
200/// the edge: an entry naming a downstream role beside it still owes that one.
201/// An origin this client does not hold as a role — a principal naming none, or
202/// no origin at all — buys no exclusion, which keeps the guard strict wherever
203/// the exclusion cannot be justified.
204///
205/// The refusal names every role still owed and the set the session actually
206/// delivered to. That sentence is what a model reads, and it is the same one
207/// both drives see — the guard is here, where the frame is handled, so the pi
208/// drive and the ACP drive refuse identically.
209fn relay_refusal(inner: &DispatchInner, key: &str) -> Option<String> {
210    let slot = inner.sessions.get(key)?;
211    if inner.required_targets.is_empty() {
212        return None;
213    }
214    let origin = slot.origin.as_ref().and_then(Principal::role_name);
215    let missing: Vec<&str> = inner
216        .required_targets
217        .iter()
218        .filter(|role| {
219            Some(role.as_str()) != origin && !slot.delivered_roles.contains(role.as_str())
220        })
221        .map(String::as_str)
222        .collect();
223    if missing.is_empty() {
224        return None;
225    }
226    let delivered = if slot.delivered_roles.is_empty() {
227        "none".to_string()
228    } else {
229        slot.delivered_roles
230            .iter()
231            .cloned()
232            .collect::<Vec<_>>()
233            .join(", ")
234    };
235    Some(format!(
236        "relay guard: missing handoff to: {} (this session delivered to: {delivered})",
237        missing.join(", "),
238    ))
239}
240
241/// The shape rule one completion carries: `details` inside the cap, and `files`
242/// naming absolute paths.
243fn shape_refusal(report: &Report) -> Option<(String, &'static str)> {
244    let Report::Complete { details, files, .. } = report else {
245        return None;
246    };
247    if let Some(details) = details {
248        if details.len() > DETAILS_MAX_BYTES {
249            return Some((
250                format!("details exceeds the {DETAILS_MAX_BYTES}-byte cap"),
251                "details",
252            ));
253        }
254    }
255    for path in files {
256        if !Path::new(path).is_absolute() {
257            return Some((format!("files must name absolute paths: {path}"), "files"));
258        }
259    }
260    None
261}
262
263impl DispatchState {
264    /// The refusal one incoming completion meets, when a client-side constraint
265    /// refuses it; `None` when the frame may settle.
266    ///
267    /// `from` is the connection the frame arrived on. A completion that arrives
268    /// with no connection — the local operator surface, and the fault a refused
269    /// `focus` files — is measured against the shape rule alone: the relay guard
270    /// reads the session's own delivery record, and a door with no sender has no
271    /// session whose record it could read.
272    pub fn completion_refusal(&self, from: Option<&AdapterIo>, report: &Report) -> Option<ResBody> {
273        if let Some((message, field)) = shape_refusal(report) {
274            return Some(ResBody::err(
275                ErrorCode::Invalid,
276                message,
277                Some(field.to_string()),
278            ));
279        }
280        let Report::Complete { .. } = report else {
281            return None;
282        };
283        let io = from?;
284        let inner = self.inner.lock();
285        let key = session_key_of_connection(&inner, io)?;
286        let message = relay_refusal(&inner, &key)?;
287        Some(ResBody::err(ErrorCode::Invalid, message, None))
288    }
289
290    /// The refusal one `handoff` frame meets when the family's hop budget is
291    /// spent; `None` when the frame may be built.
292    ///
293    /// A child sits one hop below the task that hands it on, and a family's
294    /// `hop_budget` is the depth the chain may reach: the frame that would sit
295    /// over it is refused here and names the budget it would break, rather than
296    /// being minted for the server to sort out. A frame from a connection that
297    /// does not serve the task is left for `plugin_handoff`'s own authority
298    /// answer, so this check reveals nothing to a foreign connection.
299    pub fn handoff_refusal(&self, io: &AdapterIo, args: &HandoffArgs) -> Option<ResBody> {
300        let inner = self.inner.lock();
301        let key = slot_key_serving_task(&inner, &args.task_id)?;
302        if !serves_session(&inner, &key, io) {
303            return None;
304        }
305        hop_refusal(&inner, &key, "handoff")
306    }
307}
308
309/// The hop budget's own refusal for one session, naming the frame it answered.
310///
311/// A child sits one hop below the task its session serves, and a family's
312/// `hop_budget` is the depth the chain may reach: the frame that would sit over
313/// it is refused here and names the budget it would break, rather than being
314/// minted for the server to sort out. Only a `handoff` reaches this door: a
315/// `send` starts a family of its own and spends no hop of this one.
316fn hop_refusal(inner: &DispatchInner, key: &str, what: &str) -> Option<ResBody> {
317    let slot = inner.sessions.get(key)?;
318    let budget = slot.causality.hop_budget?;
319    let next = slot.causality.hop.saturating_add(1);
320    (next > budget).then(|| {
321        ResBody::err(
322            ErrorCode::Invalid,
323            format!("hop budget exhausted: this {what} would sit at hop {next} of {budget}"),
324            None,
325        )
326    })
327}
328
329#[cfg(test)]
330mod tests {
331    use super::shape_refusal;
332    use onlyne_proto::{DETAILS_MAX_BYTES, Outcome, Report};
333
334    /// One completion's shape, at the client's own door.
335    ///
336    /// The plan names this case by hand: "`complete` carrying a `details` body
337    /// over the cap is refused with the cap named, and one at the cap passes"
338    /// (`docs/v2-CONTRACT.md` §3c). The boundary is `>` and not `>=`, so the
339    /// table carries the exact cap and one byte over it — a guard that read
340    /// `>=` would refuse a completion the plan says passes, and a model would be
341    /// told it had written too much when it had written exactly the limit.
342    #[test]
343    fn a_completion_at_the_cap_passes_and_one_byte_over_it_is_refused() {
344        let at_cap = "x".repeat(DETAILS_MAX_BYTES);
345        let over = "x".repeat(DETAILS_MAX_BYTES + 1);
346
347        let at = shape_refusal(&complete(Some(at_cap.clone()), &[]));
348        assert_eq!(at, None, "the cap itself is inside the cap");
349
350        let over = shape_refusal(&complete(Some(over), &[]));
351        let (message, field) = over.expect("one byte over the cap is refused");
352        assert_eq!(field, "details", "the refusal names the field");
353        assert!(
354            message.contains(&DETAILS_MAX_BYTES.to_string()),
355            "the refusal names the cap it measured against, so the model can \
356             count its own bytes: {message}"
357        );
358
359        // No details at all is not a zero-length details: an absent field is
360        // absent, and a cap that read `None` as empty would refuse a completion
361        // that simply reported nothing.
362        assert_eq!(shape_refusal(&complete(None, &[])), None);
363    }
364
365    /// `files` names paths the model is expected to read.
366    ///
367    /// A relative path resolves against whatever the runtime's working directory
368    /// happens to be — a pane, a tab, a child's cwd — so a completion carrying
369    /// one points the next agent at a file that is not there, and the delivery
370    /// reads as a truncated result rather than a wrong one. The refusal quotes
371    /// the offending path so the model can see which of its own entries was the
372    /// problem.
373    #[test]
374    fn a_file_that_is_not_an_absolute_path_is_refused_by_name() {
375        let refused = shape_refusal(&complete(
376            None,
377            &["relative/out.txt".to_string(), "/abs/ok.txt".to_string()],
378        ));
379        let (message, field) = refused.expect("a relative path is refused");
380        assert_eq!(field, "files", "the refusal names the field");
381        assert!(
382            message.contains("relative/out.txt"),
383            "the refusal quotes the path: {message}"
384        );
385
386        assert_eq!(
387            shape_refusal(&complete(None, &["/abs/a.png".to_string()])),
388            None,
389            "an absolute path is the shape the contract asks for"
390        );
391        assert_eq!(
392            shape_refusal(&complete(None, &[])),
393            None,
394            "no files, no rule"
395        );
396    }
397
398    /// The shape rule reads one report, and a report that is not a completion has
399    /// no shape to check.
400    #[test]
401    fn a_report_that_is_not_a_completion_is_never_refused_for_shape() {
402        let ready = Report::Ready {
403            task_id: "t-1".to_string(),
404            session_id: "s-1".to_string(),
405            generation: 1,
406            seq: 7,
407            cluster_ref: None,
408        };
409        assert_eq!(shape_refusal(&ready), None);
410    }
411
412    fn complete(details: Option<String>, files: &[String]) -> Report {
413        Report::Complete {
414            task_id: "t-1".to_string(),
415            outcome: Outcome::Done,
416            head: Some("done".to_string()),
417            details,
418            files: files.to_vec(),
419            reply_to: None,
420            cluster_ref: None,
421        }
422    }
423}