Skip to main content

capsule_runtime/
origin.rs

1//! Why a task woke the capsule, and how far its content is trusted.
2//!
3//! An origin is set by whoever enqueued the task. A trust class is *derived* from that origin
4//! and, for the two origins that cross a capsule boundary, from the trust of the sending
5//! capsule's own current task. No sender declares its trust class: [`TaskProvenance`] has no
6//! constructor that takes one on its own, so "derived, never declared" is a property of the type
7//! rather than a convention callers are asked to keep.
8//!
9//! Nothing in the runtime branches on trust: the value is recorded on `task_start` and left on
10//! the store state for the task's duration, and no task is refused, delayed or reordered for it.
11//! The origin half is not inert — it picks the queue lane a task waits in, so two tasks with the
12//! same trust can still run in a different order. See [`crate::lanes`].
13
14/// Header carrying the sending runtime's origin claim on an inter-capsule request.
15///
16/// Stamped by the sending *runtime*, never by a guest component: `murmur:message/send` has no
17/// field a capsule author could put an origin in.
18pub const PEER_ORIGIN_HEADER: &str = "x-murmur-task-origin";
19
20/// Header carrying the trust class of the sending capsule's own current task.
21pub const PEER_TRUST_HEADER: &str = "x-murmur-task-trust";
22
23/// Why the runtime woke the capsule.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TaskOrigin {
26    /// A person handed the capsule an instruction — a local `mur run`, a `task.md`.
27    User,
28    /// A message from another capsule, carrying that capsule's own trust class.
29    Peer,
30    /// A timer fired.
31    Schedule,
32    /// A webhook, chat message or PR comment — third-party text, so never trusted.
33    Event,
34    /// A sub-capsule or detached shell reporting that its work finished. Inherits like
35    /// [`TaskOrigin::Peer`]: it is the same boundary, seen from the other end.
36    Completion,
37    /// The runtime enqueued this for itself with no person in the loop — a retry, a sweep.
38    System,
39}
40
41impl TaskOrigin {
42    /// The lowercase wire spelling, as it appears in a header value and in a trace record.
43    pub fn as_str(&self) -> &'static str {
44        match self {
45            Self::User => "user",
46            Self::Peer => "peer",
47            Self::Schedule => "schedule",
48            Self::Event => "event",
49            Self::Completion => "completion",
50            Self::System => "system",
51        }
52    }
53
54    /// Exact match against [`Self::as_str`]. No aliases and no case folding — an inbound header
55    /// arrives already lowercased, and every other caller is in-crate.
56    pub fn parse(value: &str) -> Option<Self> {
57        match value {
58            "user" => Some(Self::User),
59            "peer" => Some(Self::Peer),
60            "schedule" => Some(Self::Schedule),
61            "event" => Some(Self::Event),
62            "completion" => Some(Self::Completion),
63            "system" => Some(Self::System),
64            _ => None,
65        }
66    }
67}
68
69/// How far the content of a task is trusted.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum TrustClass {
72    Trusted,
73    Untrusted,
74}
75
76impl TrustClass {
77    /// The lowercase wire spelling, as it appears in a header value and in a trace record.
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            Self::Trusted => "trusted",
81            Self::Untrusted => "untrusted",
82        }
83    }
84
85    /// Exact match against [`Self::as_str`], on the same terms as [`TaskOrigin::parse`].
86    pub fn parse(value: &str) -> Option<Self> {
87        match value {
88            "trusted" => Some(Self::Trusted),
89            "untrusted" => Some(Self::Untrusted),
90            _ => None,
91        }
92    }
93}
94
95/// An origin paired with the trust class derived from it.
96///
97/// Both fields are private and the only constructor is [`Self::derive`], so there is no way to
98/// pair an origin with a trust class that does not follow from it.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct TaskProvenance {
101    origin: TaskOrigin,
102    trust: TrustClass,
103}
104
105impl TaskProvenance {
106    /// Derive the trust class for `origin`.
107    ///
108    /// `inherited` is the trust of the sending capsule's own current task, and is consulted only
109    /// for [`TaskOrigin::Peer`] and [`TaskOrigin::Completion`] — the two origins that cross a
110    /// capsule boundary. Passing `None` for either means the sender made no claim, which
111    /// resolves to [`TrustClass::Untrusted`]: an absent claim is not a trusted one, so untrust
112    /// cannot launder itself by dropping the header at the first hop.
113    pub fn derive(origin: TaskOrigin, inherited: Option<TrustClass>) -> Self {
114        let trust = match origin {
115            TaskOrigin::User | TaskOrigin::Schedule | TaskOrigin::System => TrustClass::Trusted,
116            TaskOrigin::Event => TrustClass::Untrusted,
117            TaskOrigin::Peer | TaskOrigin::Completion => inherited.unwrap_or(TrustClass::Untrusted),
118        };
119        Self { origin, trust }
120    }
121
122    pub fn origin(&self) -> TaskOrigin {
123        self.origin
124    }
125
126    pub fn trust(&self) -> TrustClass {
127        self.trust
128    }
129}
130
131/// Classify a task arriving over the peer door from the two request headers.
132///
133/// Only `peer` and `completion` are accepted from the wire. `user`, `schedule`, `event` and
134/// `system` are enqueued locally by the CLI or by the runtime and never legitimately arrive over
135/// HTTP, so a caller naming one — or naming anything unrecognised, or sending no origin header
136/// at all — is classified `event` / `untrusted` and its trust header is not read. No HTTP caller
137/// can talk itself into a trusted class it was not given by a murmur runtime.
138///
139/// This does not authenticate the door: a caller that claims `peer` + `trusted` gets what a
140/// genuine trusted peer gets, and nothing on the A2A path tells the two apart. The boundary
141/// closed here is untrust laundering across an honest chain.
142pub fn from_wire(origin_header: Option<&str>, trust_header: Option<&str>) -> TaskProvenance {
143    let origin = origin_header.and_then(TaskOrigin::parse);
144    match origin {
145        Some(origin @ (TaskOrigin::Peer | TaskOrigin::Completion)) => {
146            TaskProvenance::derive(origin, trust_header.and_then(TrustClass::parse))
147        }
148        _ => TaskProvenance::derive(TaskOrigin::Event, None),
149    }
150}
151
152/// The provenance a runtime stamps on an outbound peer message, given its own current task.
153///
154/// The origin is always [`TaskOrigin::Peer`]; a completion is stamped by
155/// [`stamp_for_completion`] instead, which is the other origin the inbound rule accepts. The
156/// trust class is the sending task's own, so untrust survives the hop. `None`, meaning no task is
157/// in scope, stamps [`TrustClass::Untrusted`].
158pub fn stamp_for_peer(sender_task: Option<TaskProvenance>) -> TaskProvenance {
159    TaskProvenance::derive(TaskOrigin::Peer, sender_task.map(|task| task.trust()))
160}
161
162/// The provenance a runtime stamps on a completion it posts to the capsule that delegated to it.
163///
164/// `inherited` is the trust class of the parent task that made the delegation, carried to the
165/// child in its [`crate::delegation::SpawnerHandle`]. It is not a second decision about trust:
166/// the class was derived when the delegating task arrived, and this hands the same class back so
167/// the completion inherits it rather than being reclassified as fresh. `None` stamps
168/// [`TrustClass::Untrusted`], the safe class.
169pub fn stamp_for_completion(inherited: Option<TrustClass>) -> TaskProvenance {
170    TaskProvenance::derive(TaskOrigin::Completion, inherited)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    const ALL_ORIGINS: [TaskOrigin; 6] = [
178        TaskOrigin::User,
179        TaskOrigin::Peer,
180        TaskOrigin::Schedule,
181        TaskOrigin::Event,
182        TaskOrigin::Completion,
183        TaskOrigin::System,
184    ];
185
186    #[test]
187    fn origin_round_trips_through_its_wire_spelling() {
188        for origin in ALL_ORIGINS {
189            assert_eq!(TaskOrigin::parse(origin.as_str()), Some(origin));
190        }
191        assert_eq!(TaskOrigin::parse("User"), None);
192        assert_eq!(TaskOrigin::parse("not-an-origin"), None);
193        assert_eq!(TaskOrigin::parse(""), None);
194    }
195
196    #[test]
197    fn trust_round_trips_through_its_wire_spelling() {
198        for trust in [TrustClass::Trusted, TrustClass::Untrusted] {
199            assert_eq!(TrustClass::parse(trust.as_str()), Some(trust));
200        }
201        assert_eq!(TrustClass::parse("Trusted"), None);
202        assert_eq!(TrustClass::parse("maybe"), None);
203    }
204
205    /// The full derivation table: six origins crossed with every inherited value.
206    #[test]
207    fn derive_covers_every_origin_and_inherited_pair() {
208        let cases: [(TaskOrigin, Option<TrustClass>, TrustClass); 18] = [
209            (TaskOrigin::User, None, TrustClass::Trusted),
210            (
211                TaskOrigin::User,
212                Some(TrustClass::Trusted),
213                TrustClass::Trusted,
214            ),
215            (
216                TaskOrigin::User,
217                Some(TrustClass::Untrusted),
218                TrustClass::Trusted,
219            ),
220            (TaskOrigin::Schedule, None, TrustClass::Trusted),
221            (
222                TaskOrigin::Schedule,
223                Some(TrustClass::Trusted),
224                TrustClass::Trusted,
225            ),
226            (
227                TaskOrigin::Schedule,
228                Some(TrustClass::Untrusted),
229                TrustClass::Trusted,
230            ),
231            (TaskOrigin::System, None, TrustClass::Trusted),
232            (
233                TaskOrigin::System,
234                Some(TrustClass::Trusted),
235                TrustClass::Trusted,
236            ),
237            (
238                TaskOrigin::System,
239                Some(TrustClass::Untrusted),
240                TrustClass::Trusted,
241            ),
242            (TaskOrigin::Event, None, TrustClass::Untrusted),
243            (
244                TaskOrigin::Event,
245                Some(TrustClass::Trusted),
246                TrustClass::Untrusted,
247            ),
248            (
249                TaskOrigin::Event,
250                Some(TrustClass::Untrusted),
251                TrustClass::Untrusted,
252            ),
253            (TaskOrigin::Peer, None, TrustClass::Untrusted),
254            (
255                TaskOrigin::Peer,
256                Some(TrustClass::Trusted),
257                TrustClass::Trusted,
258            ),
259            (
260                TaskOrigin::Peer,
261                Some(TrustClass::Untrusted),
262                TrustClass::Untrusted,
263            ),
264            (TaskOrigin::Completion, None, TrustClass::Untrusted),
265            (
266                TaskOrigin::Completion,
267                Some(TrustClass::Trusted),
268                TrustClass::Trusted,
269            ),
270            (
271                TaskOrigin::Completion,
272                Some(TrustClass::Untrusted),
273                TrustClass::Untrusted,
274            ),
275        ];
276        for (origin, inherited, expected) in cases {
277            let provenance = TaskProvenance::derive(origin, inherited);
278            assert_eq!(
279                provenance.trust(),
280                expected,
281                "{origin:?} with {inherited:?} should be {expected:?}"
282            );
283            assert_eq!(provenance.origin(), origin);
284        }
285    }
286
287    #[test]
288    fn wire_accepts_only_peer_and_completion() {
289        for accepted in ["peer", "completion"] {
290            let provenance = from_wire(Some(accepted), Some("trusted"));
291            assert_eq!(provenance.origin(), TaskOrigin::parse(accepted).unwrap());
292            assert_eq!(provenance.trust(), TrustClass::Trusted);
293        }
294    }
295
296    #[test]
297    fn wire_refuses_locally_only_and_unrecognised_origins() {
298        for refused in ["user", "schedule", "event", "system", "not-an-origin", ""] {
299            let provenance = from_wire(Some(refused), Some("trusted"));
300            assert_eq!(
301                provenance.origin(),
302                TaskOrigin::Event,
303                "{refused:?} must not be accepted from the wire"
304            );
305            assert_eq!(provenance.trust(), TrustClass::Untrusted);
306        }
307    }
308
309    #[test]
310    fn wire_without_an_origin_header_is_an_untrusted_event() {
311        let provenance = from_wire(None, Some("trusted"));
312        assert_eq!(provenance.origin(), TaskOrigin::Event);
313        assert_eq!(provenance.trust(), TrustClass::Untrusted);
314    }
315
316    #[test]
317    fn wire_trust_defaults_to_untrusted_when_absent_or_unparseable() {
318        for trust_header in [None, Some("Trusted"), Some("yes"), Some("")] {
319            let provenance = from_wire(Some("peer"), trust_header);
320            assert_eq!(provenance.origin(), TaskOrigin::Peer);
321            assert_eq!(
322                provenance.trust(),
323                TrustClass::Untrusted,
324                "trust header {trust_header:?} must not resolve to trusted"
325            );
326        }
327    }
328
329    #[test]
330    fn stamp_for_peer_carries_the_senders_class_and_defaults_untrusted() {
331        assert_eq!(stamp_for_peer(None).origin(), TaskOrigin::Peer);
332        assert_eq!(stamp_for_peer(None).trust(), TrustClass::Untrusted);
333        for (sender, expected) in [
334            (TaskOrigin::User, TrustClass::Trusted),
335            (TaskOrigin::Event, TrustClass::Untrusted),
336        ] {
337            let stamped = stamp_for_peer(Some(TaskProvenance::derive(sender, None)));
338            assert_eq!(stamped.origin(), TaskOrigin::Peer);
339            assert_eq!(stamped.trust(), expected);
340        }
341    }
342
343    /// A completion inherits the delegating task's class and decides nothing of its own.
344    #[test]
345    fn stamp_for_completion_carries_the_delegating_tasks_class() {
346        assert_eq!(stamp_for_completion(None).origin(), TaskOrigin::Completion);
347        assert_eq!(stamp_for_completion(None).trust(), TrustClass::Untrusted);
348        for trust in [TrustClass::Trusted, TrustClass::Untrusted] {
349            let stamped = stamp_for_completion(Some(trust));
350            assert_eq!(stamped.origin(), TaskOrigin::Completion);
351            assert_eq!(stamped.trust(), trust);
352        }
353    }
354
355    #[test]
356    fn wire_untrusted_peer_stays_untrusted() {
357        let provenance = from_wire(Some("peer"), Some("untrusted"));
358        assert_eq!(provenance.origin(), TaskOrigin::Peer);
359        assert_eq!(provenance.trust(), TrustClass::Untrusted);
360    }
361}