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 detached shell or a sub-capsule reporting that its work finished. Inherits like
35    /// [`TaskOrigin::Peer`]: it is the same boundary, seen from the other end. A sub-capsule's
36    /// completion is classified by this word at the door, which hands it to the task that made
37    /// the delegation rather than running it as a task.
38    Completion,
39    /// The runtime enqueued this for itself with no person in the loop — a retry, a sweep.
40    System,
41}
42
43impl TaskOrigin {
44    /// The lowercase wire spelling, as it appears in a header value and in a trace record.
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            Self::User => "user",
48            Self::Peer => "peer",
49            Self::Schedule => "schedule",
50            Self::Event => "event",
51            Self::Completion => "completion",
52            Self::System => "system",
53        }
54    }
55
56    /// Exact match against [`Self::as_str`]. No aliases and no case folding — an inbound header
57    /// arrives already lowercased, and every other caller is in-crate.
58    pub fn parse(value: &str) -> Option<Self> {
59        match value {
60            "user" => Some(Self::User),
61            "peer" => Some(Self::Peer),
62            "schedule" => Some(Self::Schedule),
63            "event" => Some(Self::Event),
64            "completion" => Some(Self::Completion),
65            "system" => Some(Self::System),
66            _ => None,
67        }
68    }
69}
70
71/// How far the content of a task is trusted.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum TrustClass {
74    Trusted,
75    Untrusted,
76}
77
78impl TrustClass {
79    /// The lowercase wire spelling, as it appears in a header value and in a trace record.
80    pub fn as_str(&self) -> &'static str {
81        match self {
82            Self::Trusted => "trusted",
83            Self::Untrusted => "untrusted",
84        }
85    }
86
87    /// Exact match against [`Self::as_str`], on the same terms as [`TaskOrigin::parse`].
88    pub fn parse(value: &str) -> Option<Self> {
89        match value {
90            "trusted" => Some(Self::Trusted),
91            "untrusted" => Some(Self::Untrusted),
92            _ => None,
93        }
94    }
95}
96
97/// An origin paired with the trust class derived from it.
98///
99/// Both fields are private and the only constructor is [`Self::derive`], so there is no way to
100/// pair an origin with a trust class that does not follow from it.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct TaskProvenance {
103    origin: TaskOrigin,
104    trust: TrustClass,
105}
106
107impl TaskProvenance {
108    /// Derive the trust class for `origin`.
109    ///
110    /// `inherited` is the trust of the sending capsule's own current task, and is consulted only
111    /// for [`TaskOrigin::Peer`] and [`TaskOrigin::Completion`] — the two origins that cross a
112    /// capsule boundary. Passing `None` for either means the sender made no claim, which
113    /// resolves to [`TrustClass::Untrusted`]: an absent claim is not a trusted one, so untrust
114    /// cannot launder itself by dropping the header at the first hop.
115    pub fn derive(origin: TaskOrigin, inherited: Option<TrustClass>) -> Self {
116        let trust = match origin {
117            TaskOrigin::User | TaskOrigin::Schedule | TaskOrigin::System => TrustClass::Trusted,
118            TaskOrigin::Event => TrustClass::Untrusted,
119            TaskOrigin::Peer | TaskOrigin::Completion => inherited.unwrap_or(TrustClass::Untrusted),
120        };
121        Self { origin, trust }
122    }
123
124    pub fn origin(&self) -> TaskOrigin {
125        self.origin
126    }
127
128    pub fn trust(&self) -> TrustClass {
129        self.trust
130    }
131}
132
133/// Classify a task arriving over the peer door from the two request headers.
134///
135/// Only `peer` and `completion` are accepted from the wire. `user`, `schedule`, `event` and
136/// `system` are enqueued locally by the CLI or by the runtime and never legitimately arrive over
137/// HTTP, so a caller naming one — or naming anything unrecognised, or sending no origin header
138/// at all — is classified `event` / `untrusted` and its trust header is not read. No HTTP caller
139/// can talk itself into a trusted class it was not given by a murmur runtime.
140///
141/// Provenance is the caller's claim, whether or not the door authenticates. A door declaring
142/// `network.authentication` decides *who may call*: an operator or declared credential, or — on a
143/// formation member's door — another member, let in as the grant `member:<caller>` on the
144/// formation token the launcher issued it for this door. No credential binds a trust class, so any
145/// caller a door lets in that claims `peer` + `trusted` gets what a genuine trusted peer gets, and
146/// on a public door that is any caller at all. Formation calls narrow this: the runtime presents
147/// the token, so it drops whatever origin and trust its guest set and stamps [`stamp_for_peer`] of
148/// the sending task itself. The boundary closed here is untrust laundering across an honest chain.
149pub fn from_wire(origin_header: Option<&str>, trust_header: Option<&str>) -> TaskProvenance {
150    let origin = origin_header.and_then(TaskOrigin::parse);
151    match origin {
152        Some(origin @ (TaskOrigin::Peer | TaskOrigin::Completion)) => {
153            TaskProvenance::derive(origin, trust_header.and_then(TrustClass::parse))
154        }
155        _ => TaskProvenance::derive(TaskOrigin::Event, None),
156    }
157}
158
159/// The provenance a runtime stamps on an outbound peer message, given its own current task.
160///
161/// The origin is always [`TaskOrigin::Peer`]; a completion carries [`TaskOrigin::Completion`]
162/// instead, which is the other origin the inbound rule accepts. The
163/// trust class is the sending task's own, so untrust survives the hop. `None`, meaning no task is
164/// in scope, stamps [`TrustClass::Untrusted`].
165pub fn stamp_for_peer(sender_task: Option<TaskProvenance>) -> TaskProvenance {
166    TaskProvenance::derive(TaskOrigin::Peer, sender_task.map(|task| task.trust()))
167}
168
169/// How far an installed artifact's text is trusted, from who pinned it in `murmur.lock`.
170///
171/// A pin the operator wrote is [`TrustClass::Trusted`]; a pin a running capsule fetched through
172/// `manage.pull()` is [`TrustClass::Untrusted`] whatever session it names, an empty one
173/// included. The lock origin is the only input, so no runtime pin can come out trusted.
174///
175/// Like a task's trust class this is a marker, not a control: it decides whether the artifact's
176/// tool-array entry carries the runtime-origin marker and whether its skill guidance is fenced,
177/// and nothing is refused, delayed or regranted for it.
178pub fn artifact_trust(origin: &murmur_artifact::LockOrigin) -> TrustClass {
179    match origin {
180        murmur_artifact::LockOrigin::Operator => TrustClass::Trusted,
181        murmur_artifact::LockOrigin::Runtime { .. } => TrustClass::Untrusted,
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const ALL_ORIGINS: [TaskOrigin; 6] = [
190        TaskOrigin::User,
191        TaskOrigin::Peer,
192        TaskOrigin::Schedule,
193        TaskOrigin::Event,
194        TaskOrigin::Completion,
195        TaskOrigin::System,
196    ];
197
198    #[test]
199    fn origin_round_trips_through_its_wire_spelling() {
200        for origin in ALL_ORIGINS {
201            assert_eq!(TaskOrigin::parse(origin.as_str()), Some(origin));
202        }
203        assert_eq!(TaskOrigin::parse("User"), None);
204        assert_eq!(TaskOrigin::parse("not-an-origin"), None);
205        assert_eq!(TaskOrigin::parse(""), None);
206    }
207
208    #[test]
209    fn trust_round_trips_through_its_wire_spelling() {
210        for trust in [TrustClass::Trusted, TrustClass::Untrusted] {
211            assert_eq!(TrustClass::parse(trust.as_str()), Some(trust));
212        }
213        assert_eq!(TrustClass::parse("Trusted"), None);
214        assert_eq!(TrustClass::parse("maybe"), None);
215    }
216
217    /// The full derivation table: six origins crossed with every inherited value.
218    #[test]
219    fn derive_covers_every_origin_and_inherited_pair() {
220        let cases: [(TaskOrigin, Option<TrustClass>, TrustClass); 18] = [
221            (TaskOrigin::User, None, TrustClass::Trusted),
222            (
223                TaskOrigin::User,
224                Some(TrustClass::Trusted),
225                TrustClass::Trusted,
226            ),
227            (
228                TaskOrigin::User,
229                Some(TrustClass::Untrusted),
230                TrustClass::Trusted,
231            ),
232            (TaskOrigin::Schedule, None, TrustClass::Trusted),
233            (
234                TaskOrigin::Schedule,
235                Some(TrustClass::Trusted),
236                TrustClass::Trusted,
237            ),
238            (
239                TaskOrigin::Schedule,
240                Some(TrustClass::Untrusted),
241                TrustClass::Trusted,
242            ),
243            (TaskOrigin::System, None, TrustClass::Trusted),
244            (
245                TaskOrigin::System,
246                Some(TrustClass::Trusted),
247                TrustClass::Trusted,
248            ),
249            (
250                TaskOrigin::System,
251                Some(TrustClass::Untrusted),
252                TrustClass::Trusted,
253            ),
254            (TaskOrigin::Event, None, TrustClass::Untrusted),
255            (
256                TaskOrigin::Event,
257                Some(TrustClass::Trusted),
258                TrustClass::Untrusted,
259            ),
260            (
261                TaskOrigin::Event,
262                Some(TrustClass::Untrusted),
263                TrustClass::Untrusted,
264            ),
265            (TaskOrigin::Peer, None, TrustClass::Untrusted),
266            (
267                TaskOrigin::Peer,
268                Some(TrustClass::Trusted),
269                TrustClass::Trusted,
270            ),
271            (
272                TaskOrigin::Peer,
273                Some(TrustClass::Untrusted),
274                TrustClass::Untrusted,
275            ),
276            (TaskOrigin::Completion, None, TrustClass::Untrusted),
277            (
278                TaskOrigin::Completion,
279                Some(TrustClass::Trusted),
280                TrustClass::Trusted,
281            ),
282            (
283                TaskOrigin::Completion,
284                Some(TrustClass::Untrusted),
285                TrustClass::Untrusted,
286            ),
287        ];
288        for (origin, inherited, expected) in cases {
289            let provenance = TaskProvenance::derive(origin, inherited);
290            assert_eq!(
291                provenance.trust(),
292                expected,
293                "{origin:?} with {inherited:?} should be {expected:?}"
294            );
295            assert_eq!(provenance.origin(), origin);
296        }
297    }
298
299    #[test]
300    fn wire_accepts_only_peer_and_completion() {
301        for accepted in ["peer", "completion"] {
302            let provenance = from_wire(Some(accepted), Some("trusted"));
303            assert_eq!(provenance.origin(), TaskOrigin::parse(accepted).unwrap());
304            assert_eq!(provenance.trust(), TrustClass::Trusted);
305        }
306    }
307
308    #[test]
309    fn wire_refuses_locally_only_and_unrecognised_origins() {
310        for refused in ["user", "schedule", "event", "system", "not-an-origin", ""] {
311            let provenance = from_wire(Some(refused), Some("trusted"));
312            assert_eq!(
313                provenance.origin(),
314                TaskOrigin::Event,
315                "{refused:?} must not be accepted from the wire"
316            );
317            assert_eq!(provenance.trust(), TrustClass::Untrusted);
318        }
319    }
320
321    #[test]
322    fn wire_without_an_origin_header_is_an_untrusted_event() {
323        let provenance = from_wire(None, Some("trusted"));
324        assert_eq!(provenance.origin(), TaskOrigin::Event);
325        assert_eq!(provenance.trust(), TrustClass::Untrusted);
326    }
327
328    #[test]
329    fn wire_trust_defaults_to_untrusted_when_absent_or_unparseable() {
330        for trust_header in [None, Some("Trusted"), Some("yes"), Some("")] {
331            let provenance = from_wire(Some("peer"), trust_header);
332            assert_eq!(provenance.origin(), TaskOrigin::Peer);
333            assert_eq!(
334                provenance.trust(),
335                TrustClass::Untrusted,
336                "trust header {trust_header:?} must not resolve to trusted"
337            );
338        }
339    }
340
341    #[test]
342    fn stamp_for_peer_carries_the_senders_class_and_defaults_untrusted() {
343        assert_eq!(stamp_for_peer(None).origin(), TaskOrigin::Peer);
344        assert_eq!(stamp_for_peer(None).trust(), TrustClass::Untrusted);
345        for (sender, expected) in [
346            (TaskOrigin::User, TrustClass::Trusted),
347            (TaskOrigin::Event, TrustClass::Untrusted),
348        ] {
349            let stamped = stamp_for_peer(Some(TaskProvenance::derive(sender, None)));
350            assert_eq!(stamped.origin(), TaskOrigin::Peer);
351            assert_eq!(stamped.trust(), expected);
352        }
353    }
354
355    #[test]
356    fn artifact_trust_of_an_operator_pin_is_trusted() {
357        assert_eq!(
358            artifact_trust(&murmur_artifact::LockOrigin::Operator),
359            TrustClass::Trusted
360        );
361    }
362
363    #[test]
364    fn artifact_trust_of_a_runtime_pin_is_untrusted_for_any_session() {
365        for session in ["ses_puller", "", "operator", "trusted"] {
366            let origin = murmur_artifact::LockOrigin::Runtime {
367                session: session.to_string(),
368            };
369            assert_eq!(
370                artifact_trust(&origin),
371                TrustClass::Untrusted,
372                "session {session:?} must not make a runtime pin trusted"
373            );
374        }
375    }
376
377    #[test]
378    fn wire_untrusted_peer_stays_untrusted() {
379        let provenance = from_wire(Some("peer"), Some("untrusted"));
380        assert_eq!(provenance.origin(), TaskOrigin::Peer);
381        assert_eq!(provenance.trust(), TrustClass::Untrusted);
382    }
383}