Skip to main content

boundgraph_core/
isolation.rs

1//! Which isolation tier a tool runs in, what it enforced, and the record of
2//! what actually confined a step.
3//!
4//! Named here, in the crate with no execution in it, because four layers need
5//! the same vocabulary and none of them should have to depend on the others to
6//! get it: a tool manifest *declares* a tier, the compiler *checks* it against
7//! the profile a flow claims, the sandbox *is* one and reports what it
8//! [`Confinement`] amounts to on this host, and the ledger records the
9//! [`Isolation`] a step actually ran under (US-0508).
10//!
11//! The last of those is what turns "this ran confined" from a claim into a
12//! fact: `bg plan` says which tier a tool *needs* and a tier says what it
13//! *can* do here, and neither says what happened.
14//!
15//! `ARCHITECTURE.md` D1 schedules all four — in-process now, subprocess as the
16//! Layer 3 target through M2 to M6, Wasmtime at M7 for Profile C, Firecracker
17//! later — so the enum is complete from the start even though two of them are
18//! not built yet. A tier arriving later should be a new implementation, not a
19//! new word.
20
21use std::fmt;
22
23use serde::{Deserialize, Serialize};
24
25use crate::grant::{GrantAtom, GrantSet};
26
27/// How strongly a tool is isolated from the machine it runs on.
28///
29/// Ordered by strength, weakest first, and that ordering is load-bearing:
30/// [`Tier::weakest`] is how a flow's overall isolation is summarised, and
31/// summarising it any other way would let one unconfined tool hide behind
32/// several confined ones.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum Tier {
37    /// In this process, sharing its address space. Trusted built-in tools only.
38    InProcess,
39    /// A jailed subprocess. The safe default for anything not written here.
40    Subprocess,
41    /// Wasmtime with WASI preopens. The Profile C tier.
42    Wasm,
43    /// A microVM. Scheduled after Wasm; named so the vocabulary is complete.
44    MicroVm,
45}
46
47impl Tier {
48    /// Every tier, weakest first.
49    pub const ALL: [Self; 4] = [Self::InProcess, Self::Subprocess, Self::Wasm, Self::MicroVm];
50
51    /// The name used in a manifest, a plan, and a ledger entry.
52    pub const fn name(self) -> &'static str {
53        match self {
54            Self::InProcess => "inproc",
55            Self::Subprocess => "process",
56            Self::Wasm => "wasm",
57            Self::MicroVm => "microvm",
58        }
59    }
60
61    /// Whether this tier virtualises the tool's view of the machine.
62    ///
63    /// What Profile C asks for. In-process and subprocess do not: the first
64    /// shares the address space, and the second shares the kernel's namespaces
65    /// unless something outside the tier has separated them.
66    pub const fn is_virtualising(self) -> bool {
67        matches!(self, Self::Wasm | Self::MicroVm)
68    }
69
70    /// Whether a tool has to be trusted for this tier to be safe.
71    ///
72    /// True only in-process, and it is the whole reason that tier refuses
73    /// anything not built in: a tool sharing this address space is not confined
74    /// by anything at all, so the only control left is who wrote it.
75    pub const fn requires_trust(self) -> bool {
76        matches!(self, Self::InProcess)
77    }
78
79    /// The weaker of two tiers.
80    pub const fn weaker(self, other: Self) -> Self {
81        if (self as u8) <= (other as u8) {
82            self
83        } else {
84            other
85        }
86    }
87
88    /// The weakest tier in a collection, if there is one.
89    ///
90    /// How a flow's isolation is reported. A flow is only as isolated as its
91    /// least isolated tool, and saying anything else would let one in-process
92    /// tool hide behind a dozen sandboxed ones.
93    pub fn weakest(tiers: impl IntoIterator<Item = Self>) -> Option<Self> {
94        tiers.into_iter().reduce(Self::weaker)
95    }
96}
97
98/// The safe default: anything that did not say gets a subprocess.
99///
100/// D1 calls process isolation "the safe default for untrusted code", and a
101/// manifest that forgot to declare is exactly the case that needs one. Defaulting
102/// to in-process would mean a forgotten field silently bought full trust.
103impl Default for Tier {
104    fn default() -> Self {
105        Self::Subprocess
106    }
107}
108
109impl fmt::Display for Tier {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        f.write_str(self.name())
112    }
113}
114
115/// How strongly a tier confines one resource domain.
116///
117/// Named here for the same reason [`Tier`] is: a tier *reports* one, the
118/// runtime *carries* it, and a ledger entry *records* it, so the vocabulary
119/// belongs to the crate none of the three can avoid depending on.
120///
121/// # Why the honest answer has three values
122///
123/// [`Enforcement::Kernel`] and [`Enforcement::None`] are the easy cases. The
124/// interesting one is [`Enforcement::Interposed`]: the host stands between the
125/// tool and the resource, which is real enforcement for everything that goes
126/// through the handle it was given, and no enforcement at all against a tool
127/// that makes its own syscall. An in-process tool is in exactly that position.
128///
129/// Collapsing that into "enforced" would be a lie and collapsing it into "not
130/// enforced" would make the in-process tier unusable, so it is its own value
131/// and the caller decides what it is worth. Profile C decides it is worth
132/// nothing.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "snake_case")]
135pub enum Enforcement {
136    /// The kernel refuses. A tool cannot exceed it whatever it does.
137    Kernel,
138    /// The host stands in the way.
139    ///
140    /// Real for everything reached through the handle the tool was given, and
141    /// worth nothing against a tool that makes its own syscall. See the type
142    /// documentation on why this is its own value rather than rounded to one of
143    /// the other two.
144    Interposed,
145    /// Not enforced. A tier reporting this for a domain must refuse any
146    /// capability set that touches it.
147    None,
148}
149
150impl Enforcement {
151    /// Every grade, strongest first.
152    pub const ALL: [Self; 3] = [Self::Kernel, Self::Interposed, Self::None];
153
154    /// Whether a grant in this domain can be handed out at all.
155    pub const fn permits_grants(self) -> bool {
156        !matches!(self, Self::None)
157    }
158
159    /// The name used in a report and in a ledger entry.
160    pub const fn name(self) -> &'static str {
161        match self {
162            Self::Kernel => "kernel",
163            Self::Interposed => "interposed",
164            Self::None => "none",
165        }
166    }
167
168    /// How it reads in a diagnostic.
169    pub const fn describe(self) -> &'static str {
170        match self {
171            Self::Kernel => "enforced by the kernel",
172            Self::Interposed => "enforced by the host, which a tool making its own syscalls evades",
173            Self::None => "not enforced",
174        }
175    }
176}
177
178impl fmt::Display for Enforcement {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.write_str(self.name())
181    }
182}
183
184/// What a tier actually enforces, per domain, on this host.
185///
186/// Per host, not per tier in the abstract: the subprocess tier confines a
187/// filesystem grant with a preopened directory everywhere, and confines a
188/// network grant only where the operating system will give it a network
189/// namespace. Reporting the abstract answer would be reporting the answer for
190/// somebody else's machine.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Confinement {
193    /// `fs:*` — reading, listing, writing files.
194    pub filesystem: Enforcement,
195    /// `net:*` — reaching anything off this machine.
196    pub network: Enforcement,
197    /// `proc:*` — starting other programs.
198    pub process: Enforcement,
199    /// `env:*` — reading the environment.
200    pub environment: Enforcement,
201    /// `secret:*` — using a credential.
202    pub secret: Enforcement,
203}
204
205impl Confinement {
206    /// A tier that enforces nothing. The floor to build from, so a tier claims
207    /// enforcement only by saying so.
208    pub const NONE: Self = Self {
209        filesystem: Enforcement::None,
210        network: Enforcement::None,
211        process: Enforcement::None,
212        environment: Enforcement::None,
213        secret: Enforcement::None,
214    };
215
216    /// How this tier confines the given grant domain.
217    ///
218    /// An unknown domain is [`Enforcement::None`]. The grant vocabulary is
219    /// meant to grow ([`KNOWN_DOMAINS`](crate::grant::KNOWN_DOMAINS) says so),
220    /// and a tier that treated a domain it had never heard of as confined would
221    /// be guessing in the one direction that is unsafe.
222    pub const fn of(&self, domain: &str) -> Enforcement {
223        // `match` on a `&str` in a const fn needs bytes.
224        match domain.as_bytes() {
225            b"fs" => self.filesystem,
226            b"net" => self.network,
227            b"proc" => self.process,
228            b"env" => self.environment,
229            b"secret" => self.secret,
230            _ => Enforcement::None,
231        }
232    }
233
234    /// Each domain and how it is confined, weakest-enforced domains included.
235    pub const fn domains(&self) -> [(&'static str, Enforcement); 5] {
236        [
237            ("fs", self.filesystem),
238            ("net", self.network),
239            ("proc", self.process),
240            ("env", self.environment),
241            ("secret", self.secret),
242        ]
243    }
244
245    /// Whether nothing at all is enforced.
246    ///
247    /// Not the same as saying nothing: a tier that reports this has been asked
248    /// and has answered "none", which is exactly the fact a reviewer of a
249    /// Profile C run came looking for.
250    pub fn enforces_nothing(&self) -> bool {
251        *self == Self::NONE
252    }
253
254    /// The first atom of `grants` this tier cannot enforce, if there is one.
255    ///
256    /// What a tier refuses a capability set on: a grant nothing enforces is an
257    /// advisory permission, and handing one over would make the compiler's
258    /// proof false at run time.
259    pub fn unenforceable<'g>(&self, grants: &'g GrantSet) -> Option<&'g GrantAtom> {
260        grants
261            .iter()
262            .find(|atom| !self.of(&atom.domain).permits_grants())
263    }
264}
265
266impl fmt::Display for Confinement {
267    /// `fs=kernel net=none proc=interposed env=kernel secret=interposed`.
268    ///
269    /// Every domain, every time, including the ones enforcing nothing. A
270    /// rendering that omitted them would make "not confined" and "not
271    /// mentioned" look the same, which is the distinction this type exists to
272    /// keep.
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        for (index, (domain, enforcement)) in self.domains().into_iter().enumerate() {
275            if index > 0 {
276                f.write_str(" ")?;
277            }
278            write!(f, "{domain}={enforcement}")?;
279        }
280        Ok(())
281    }
282}
283
284/// What actually confined one step (US-0508).
285///
286/// # Why this is recorded rather than inferred
287///
288/// A plan says which tier a tool *needs*, and a tier says what it *can*
289/// enforce on this host. Neither says what happened. A host that could not
290/// deliver what the plan assumed is exactly the case worth catching — a
291/// Profile C flow that silently ran in-process is a flow whose reproducibility
292/// claim is false — so the tier that ran the step writes down what it was and
293/// what it enforced, and the ledger keeps that beside the grant delta.
294///
295/// # Absent is not the same as none
296///
297/// The ledger holds an `Option<Isolation>`, and the two cases are different
298/// facts. `None` means the runner did not say: nothing is claimed, and a
299/// reviewer knows to go and ask. `Some` carrying [`Confinement::NONE`] means
300/// the step ran with nothing enforced, which is a claim, and one somebody may
301/// have to act on. Rounding either into the other would lose the difference
302/// between an unaudited step and an unconfined one.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
304pub struct Isolation {
305    /// The tier that actually ran the step.
306    pub tier: Tier,
307    /// What that tier actually enforced, per domain, on the host it ran on.
308    pub confinement: Confinement,
309    /// The reused worker that served it, where one did (US-0909).
310    ///
311    /// `None` is a child of its own: one process, one call, gone afterwards.
312    /// `Some` means a warm worker served it, and names which — because "the
313    /// tool ran in a subprocess" stops being the whole answer once a
314    /// subprocess serves more than one step. Two steps confined identically
315    /// and served by the *same* worker are a different fact from two steps
316    /// that each had a process to themselves, and only one of them can be
317    /// investigated by asking what else that worker did.
318    ///
319    /// Skipped when absent, so a cold child's entry encodes exactly as it did
320    /// before warm workers existed.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub worker: Option<Worker>,
323}
324
325/// Which reused worker served a step, and how much it had already done
326/// (US-0909).
327#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
328pub struct Worker {
329    /// Which worker, counting from one over the pool's whole life.
330    ///
331    /// A generation rather than an index: a worker that dies is replaced, and
332    /// the replacement occupying the same slot is a *different* process. Reusing
333    /// the number would make two workers' histories look like one's.
334    pub generation: u64,
335    /// How many calls it had already served when this one arrived.
336    ///
337    /// Zero for the first. What turns "worker 3 served this" into "worker 3 had
338    /// already served nine other steps when it served this", which is the
339    /// question an auditor looking at a warm pool actually has.
340    pub served: u32,
341}
342
343impl Worker {
344    /// The `served`-th call on worker `generation`.
345    pub const fn new(generation: u64, served: u32) -> Self {
346        Self { generation, served }
347    }
348
349    /// Whether this was the worker's first call, so nothing preceded it there.
350    pub const fn is_first(&self) -> bool {
351        self.served == 0
352    }
353}
354
355impl fmt::Display for Worker {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        write!(
358            f,
359            "worker {} after {} call(s)",
360            self.generation, self.served
361        )
362    }
363}
364
365impl Isolation {
366    /// A step that ran in `tier`, which enforced `confinement`.
367    ///
368    /// In a child of its own: [`served_by`](Self::served_by) is what says a
369    /// reused worker ran it instead.
370    pub const fn new(tier: Tier, confinement: Confinement) -> Self {
371        Self {
372            tier,
373            confinement,
374            worker: None,
375        }
376    }
377
378    /// Record that a reused worker served this step (US-0909).
379    #[must_use]
380    pub const fn served_by(mut self, worker: Worker) -> Self {
381        self.worker = Some(worker);
382        self
383    }
384
385    /// Whether a reused worker served it, rather than a child of its own.
386    pub const fn was_warm(&self) -> bool {
387        self.worker.is_some()
388    }
389
390    /// A step that ran in `tier` with nothing enforced at all.
391    ///
392    /// The record a trusted in-process tool leaves when nothing stands between
393    /// it and the machine. Written down rather than omitted: see the type
394    /// documentation.
395    pub const fn unconfined(tier: Tier) -> Self {
396        Self::new(tier, Confinement::NONE)
397    }
398
399    /// Whether the tier that ran it virtualises the machine — what Profile C
400    /// asks for.
401    pub const fn is_virtualising(&self) -> bool {
402        self.tier.is_virtualising()
403    }
404
405    /// Whether nothing at all was enforced.
406    pub fn enforces_nothing(&self) -> bool {
407        self.confinement.enforces_nothing()
408    }
409
410    /// Whether every domain was refused by the kernel rather than by the host.
411    ///
412    /// The question a reviewer asking "was this confined, or merely asked
413    /// nicely?" is actually asking.
414    pub fn is_kernel_enforced(&self) -> bool {
415        self.confinement
416            .domains()
417            .into_iter()
418            .all(|(_, enforcement)| enforcement == Enforcement::Kernel)
419    }
420}
421
422impl fmt::Display for Isolation {
423    /// `process (fs=kernel net=none proc=interposed env=kernel secret=interposed)`,
424    /// and where a warm worker served it, which one.
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        write!(f, "{} ({})", self.tier, self.confinement)?;
427        match self.worker {
428            Some(worker) => write!(f, ", {worker}"),
429            None => Ok(()),
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn every_tier_has_a_stable_name() {
440        for (tier, name) in [
441            (Tier::InProcess, "inproc"),
442            (Tier::Subprocess, "process"),
443            (Tier::Wasm, "wasm"),
444            (Tier::MicroVm, "microvm"),
445        ] {
446            assert_eq!(tier.name(), name);
447            assert_eq!(tier.to_string(), name);
448        }
449    }
450
451    /// Profile C asks for a virtualising sandbox, and only two tiers are one.
452    #[test]
453    fn only_the_virtualising_tiers_say_they_are() {
454        assert!(!Tier::InProcess.is_virtualising());
455        assert!(!Tier::Subprocess.is_virtualising());
456        assert!(Tier::Wasm.is_virtualising());
457        assert!(Tier::MicroVm.is_virtualising());
458    }
459
460    /// The ordering is strength, and the rest of the system relies on it.
461    #[test]
462    fn tiers_are_ordered_weakest_first() {
463        assert!(Tier::InProcess < Tier::Subprocess);
464        assert!(Tier::Subprocess < Tier::Wasm);
465        assert!(Tier::Wasm < Tier::MicroVm);
466
467        let mut shuffled = [Tier::Wasm, Tier::InProcess, Tier::MicroVm, Tier::Subprocess];
468        shuffled.sort();
469        assert_eq!(shuffled, Tier::ALL);
470    }
471
472    /// A flow is only as isolated as its least isolated tool.
473    #[test]
474    fn the_weakest_tier_is_the_one_reported() {
475        assert_eq!(
476            Tier::weakest([Tier::Wasm, Tier::InProcess, Tier::Subprocess]),
477            Some(Tier::InProcess)
478        );
479        assert_eq!(Tier::weakest([Tier::Wasm, Tier::MicroVm]), Some(Tier::Wasm));
480        assert_eq!(Tier::weakest([]), None);
481    }
482
483    /// A forgotten field must not buy full trust.
484    #[test]
485    fn the_default_is_the_safe_one_rather_than_the_convenient_one() {
486        assert_eq!(Tier::default(), Tier::Subprocess);
487        assert!(!Tier::default().requires_trust());
488    }
489
490    #[test]
491    fn only_the_in_process_tier_depends_on_trusting_the_tool() {
492        assert!(Tier::InProcess.requires_trust());
493        for tier in [Tier::Subprocess, Tier::Wasm, Tier::MicroVm] {
494            assert!(!tier.requires_trust(), "{tier}");
495        }
496    }
497
498    #[test]
499    fn a_tier_round_trips_through_its_serialised_name() {
500        for tier in Tier::ALL {
501            let json = serde_json::to_string(&tier).unwrap();
502            assert_eq!(serde_json::from_str::<Tier>(&json).unwrap(), tier);
503        }
504        assert_eq!(
505            serde_json::to_string(&Tier::InProcess).unwrap(),
506            "\"in_process\""
507        );
508    }
509
510    // ── what a tier enforced (US-0508) ──────────────────────────────────────
511
512    fn kernel_everywhere() -> Confinement {
513        Confinement {
514            filesystem: Enforcement::Kernel,
515            network: Enforcement::Kernel,
516            process: Enforcement::Kernel,
517            environment: Enforcement::Kernel,
518            secret: Enforcement::Kernel,
519        }
520    }
521
522    /// The floor claims nothing, which is what makes claiming something mean
523    /// something.
524    #[test]
525    fn the_confinement_floor_enforces_nothing_in_every_domain() {
526        assert!(Confinement::NONE.enforces_nothing());
527        for (domain, enforcement) in Confinement::NONE.domains() {
528            assert_eq!(enforcement, Enforcement::None, "{domain}");
529            assert!(!Confinement::NONE.of(domain).permits_grants());
530        }
531    }
532
533    /// A domain nobody has heard of yet is not confined, and the answer errs in
534    /// the safe direction rather than the convenient one.
535    #[test]
536    fn an_unknown_domain_is_never_reported_as_confined() {
537        assert_eq!(kernel_everywhere().of("quantum"), Enforcement::None);
538        assert!(!kernel_everywhere().of("quantum").permits_grants());
539    }
540
541    /// The distinction the whole grade exists for: interposed is enforcement,
542    /// and it is not the kernel's.
543    #[test]
544    fn interposed_permits_grants_and_is_still_not_the_kernel() {
545        assert!(Enforcement::Interposed.permits_grants());
546        assert!(Enforcement::Kernel.permits_grants());
547        assert!(!Enforcement::None.permits_grants());
548        assert_ne!(Enforcement::Interposed, Enforcement::Kernel);
549    }
550
551    /// A step that ran with nothing enforced says so, and that is different
552    /// from a runner that did not say.
553    #[test]
554    fn running_unconfined_is_a_claim_rather_than_a_silence() {
555        let ran_unconfined = Isolation::unconfined(Tier::InProcess);
556        let recorded: Option<Isolation> = Some(ran_unconfined);
557        let unrecorded: Option<Isolation> = None;
558
559        assert_ne!(recorded, unrecorded);
560        assert!(ran_unconfined.enforces_nothing());
561        assert!(!ran_unconfined.is_kernel_enforced());
562    }
563
564    #[test]
565    fn only_a_virtualising_tier_satisfies_profile_c_after_the_fact() {
566        assert!(!Isolation::unconfined(Tier::InProcess).is_virtualising());
567        assert!(!Isolation::new(Tier::Subprocess, kernel_everywhere()).is_virtualising());
568        assert!(Isolation::new(Tier::Wasm, kernel_everywhere()).is_virtualising());
569    }
570
571    /// Kernel enforcement is claimed for the whole step or not at all: one
572    /// interposed domain is enough to make the claim false.
573    #[test]
574    fn one_interposed_domain_defeats_a_kernel_claim() {
575        let mut confinement = kernel_everywhere();
576        assert!(Isolation::new(Tier::Subprocess, confinement).is_kernel_enforced());
577
578        confinement.secret = Enforcement::Interposed;
579        assert!(!Isolation::new(Tier::Subprocess, confinement).is_kernel_enforced());
580    }
581
582    /// The rendering names every domain, so "not confined" never reads as "not
583    /// mentioned".
584    #[test]
585    fn the_rendering_names_every_domain_including_the_unenforced_ones() {
586        let rendered = Isolation::unconfined(Tier::InProcess).to_string();
587        assert_eq!(
588            rendered,
589            "inproc (fs=none net=none proc=none env=none secret=none)"
590        );
591        for domain in ["fs", "net", "proc", "env", "secret"] {
592            assert!(
593                rendered.contains(domain),
594                "{domain} missing from {rendered}"
595            );
596        }
597    }
598
599    // ── which worker served it (US-0909) ────────────────────────────────────
600
601    /// A cold child and a warm worker are different facts about a step, and the
602    /// record keeps them apart.
603    #[test]
604    fn a_reused_worker_is_named_and_a_child_of_its_own_is_not() {
605        let cold = Isolation::new(Tier::Subprocess, kernel_everywhere());
606        let warm = cold.served_by(Worker::new(3, 9));
607
608        assert!(!cold.was_warm());
609        assert!(warm.was_warm());
610        assert_ne!(cold, warm, "same confinement, different history");
611
612        let worker = warm.worker.expect("a warm step names its worker");
613        assert_eq!(worker.generation, 3);
614        assert_eq!(worker.served, 9);
615        assert!(!worker.is_first());
616        assert!(Worker::new(1, 0).is_first());
617    }
618
619    /// A dead worker's replacement is a different process, so it gets a
620    /// different number rather than inheriting one.
621    #[test]
622    fn a_generation_is_not_a_slot() {
623        assert_ne!(Worker::new(1, 0), Worker::new(2, 0));
624        assert!(Worker::new(1, 5) < Worker::new(2, 0), "ordered by age");
625    }
626
627    #[test]
628    fn the_rendering_names_the_worker_only_when_there_was_one() {
629        let cold = Isolation::new(Tier::Subprocess, Confinement::NONE);
630        assert!(!cold.to_string().contains("worker"));
631        assert!(
632            cold.served_by(Worker::new(2, 4))
633                .to_string()
634                .ends_with("worker 2 after 4 call(s)")
635        );
636    }
637
638    #[test]
639    fn an_isolation_record_round_trips_through_json() {
640        for isolation in [
641            Isolation::unconfined(Tier::InProcess),
642            Isolation::new(Tier::Subprocess, kernel_everywhere()),
643            Isolation::new(Tier::Subprocess, kernel_everywhere()).served_by(Worker::new(7, 2)),
644            Isolation::new(Tier::Wasm, Confinement::NONE),
645        ] {
646            let json = serde_json::to_string(&isolation).unwrap();
647            assert_eq!(serde_json::from_str::<Isolation>(&json).unwrap(), isolation);
648        }
649    }
650}