CHERIoT Programmers' Guide

David Chisnall

Table of contents

2. CHERIoT Concepts

The CHERIoT platform is an embedded environment that provides a number of low-level features via a mixture of hardware and software features.

2.1. Introducing memory safety

Memory in a modern computer is usually arranged as a flat set of storage locations. At the lowest level, you may do a load or store operation on addresses in this space. Every location in memory is identified by a number and locations are treated as adjacent if their addresses are one apart. When you start a process or a virtual machine, this abstraction is preserved and virtual memory lets you pretend that you have a (very large) flat address space.

When you use a programming language that's higher-level than assembly, memory looks a little bit different. Rather than being a flat set of one-byte storage locations, the language exposes memory as objects. An object may be something simple, such as an integer, or something large such as an array of complex structures. On most hardware, this is purely a software abstraction. You may specify that you have an on-stack array of twelve integers, or a heap allocation containing a buffer for a network packet, but the compiled program will use numbers referring to locations in a flat memory space to represent these locations.

The term memory safety applies to a variety of properties. It is somewhat difficult to define because the problems arise when you don't have memory safety. When you do have memory safety, things simply work as you expect them to. It's therefore easier to think about memory unsafety.

Memory safety is usually split into two subcategories: temporal memory safety and spatial memory safety. When you don't have spatial memory safety, you can think that you are accessing one object, you may be accessing an adjacent one. For example, if you allocate a 12-byte on-stack buffer and then try to write 16 bytes into it, a memory-safe system will raise some kind of error. An unsafe system will instead let you write four bytes over some adjacent location, possibly a return address. This is the simplest example of how a buffer overflow can lead to arbitrary-code execution. If an attacker can overwrite the return address on the stack then they can cause the function to return somewhere else. They can chain several of these together to build rich exploits.

When you don't have temporal memory safety (sometimes called lifetime safety) it is possible to access (read or write) an object after its lifetime ends. In most language implementations, memory is reused and so accessing an object after its lifetime really means accessing an unrelated object that happens to be stored at the same place in memory.

Languages such as C and C++ are typically categorised as memory-unsafe but this really means that they allow unsafe implementations. In both languages, violations of memory safety are specified as undefined behavior. This means that an implementation is allowed to do anything if they happen. The language specifications allow this because, on most conventional hardware, dynamically checking that there are no memory-safety violations is too expensive. It is completely valid for an implementation to decide to provide reliable, deterministic, error reporting when these happen, and that's what CHERI C and C++ do.

Higher-level languages usually impose some constraints that make it easier to efficiently guarantee memory safety. For example, Java references are usually implemented as simple numerical addresses just like C pointers, but the language doesn't allow you to do arithmetic on them. This means that you can't ever do some arithmetic to turn a Java reference into a reference to another object. Similarly, it means that the Java Virtual Machine can accurately locate all references to objects. This makes it possible to implement automatic garbage collection in Java, finding all of the objects that are not reachable and deleting them rather than relying on the programmer to explicitly deallocate them.

In most C and C++ implementations there are a lot of ways of violating memory safety. For example, you can manufacture pointers from arbitrary integers that happen to match addresses and access any object.

The lack of memory safety is responsible for around 70% of critical security vulnerabilities. Memory-safety errors are usually the worst kinds of bug because it is impossible to reason about their impacts from the program source code. By definition, you are accessing some memory that you don't think that you're accessing. This memory may be an object that's completely unrelated to the running code or even something that's part of the implementation of the language and not normally directly accessible from within the language.

Attackers usually find it easy to use memory-safety vulnerabilities for arbitrary-code execution attacks. At this point, the program that is running is no longer the program that you thought you had started, but something different and under the attacker's control.

2.2. Understanding CHERI capabilities

CHERI (pronounced 'cherry') defines an abstract set of features that can be applied to a base architecture, such as AArch64, x86, or RISC-V, to provide fine-grained memory safety that can be used as a building block for compartmentalisation. CHERIoT is a concrete instantiation of the CHERI ideas that is tailed and extended for use in low-cost embedded devices. It makes sense to understand CHERI before you try to understand CHERIoT.

CHERI stands for Capability Hardware Enhanced RISC Instructions. This is a somewhat contrived acronym but it captures a few key ideas in CHERI. It's a extension to existing hardware and it doesn't require any complex microcode or look-aside structures to implement (it can be applied to RISC instruction sets). Most importantly, it's an extension that adds a capability model to the base instruction set.

A capability, in the abstract sense, is an unforgeable token of authority that must be presented to perform an operation. Capabilities exist in the physical world in various forms. For example, a key to a padlock is a capability to unlock that padlock. When the key is presented, the padlock can be unlocked, without the key the padlock cannot be unlocked without exploiting some security vulnerability, such as using lock picks or a bolt cutter. It doesn't matter to the padlock who presents the key, only that the correct key has been presented. Some complex building locks have different keys that authorise unlocking different sets of doors. For example, a team leader may have a key that unlocks the offices of everyone on their team and the building manager may hold a key that unlocks everything.

Capabilities can be delegated. The building manager may loan their key to someone else to unlock a door. The key and the door don't care who is holding them. You can create a copy of a capability that you hold and give it to someone else, just as you could go to a key cutter and have a copy made of a key that you own.

A lot of capability systems (including CHERI) allow you to reduce the rights that a capability grants. This breaks the key metaphor somewhat. If you have a master key for a building, you can't easily use it to create a key that allows just locking but not unlocking doors, or create one that opens all of the doors on the ground floor but no others, but capability systems usually do permit this kind of operation.

Some kinds of capabilities can also be revoked. This is traditionally the hardest operation to perform on capabilities. In our key analogy, this is equivalent to someone performing an audit of all of the keys and removing some of them from people that shouldn't have them anymore. This is often solved in capability systems by adding a layer of indirection. Rather than allowing capabilities to be stored anywhere, the system places them in one or more centralised tables. When you use a capability, you do so by referring to a location in a table. This makes it easy to revoke capabilities by removing them from the tables. UNIX file descriptors work like this: you refer to them by number and the kernel can invalidate them by simply removing the entry at that location in your process's file-descriptor table.

Some hardware capability systems have used a similar approach to capability storage and revocation but it has a significant disadvantage: every time that you use a capability, the hardware must find it in the relevant table. This can turn a single memory access into several. Implementations can mitigate this somewhat by caching, but these caches quickly introduce significant power overheads. CHERI avoids this entirely, which makes the common operations easier, but makes revocation somewhat more challenging. CHERIoT includes some additional hardware extensions for revocation, which we'll discuss in Chapter 8. Memory management in CHERIoT RTOS.

On a CHERI system, capabilities are used to authorise access to memory. Any instruction that takes an address in a conventional architecture takes a CHERI capability as the operand instead. The CHERI capability both describes a location in memory and grants access to it. For example, the following RISC-V snippet loads four bytes from offset eight relative to the address in register a1 and places the result in s0.

	lw	s0, 8(a1)

On a CHERIoT system, which is a CHERI RISC-V variant, this instruction looks slightly different:

	clw	s0, 8(ca1)

Now, it is loading a word into s0 from offset eight relative to the capability (not address) in register ca1 (a1 extended to hold a capability. This instruction will check that the capability in ca1 is a valid capability, check that it has load permission, and check that the range covered by the four-byte load starting at offset eight from the current address is all in bounds. If, and only if, all of these checks pass, will it do the same load as the original version. If any of these fail, the instruction will trap. The next section explains what it means for a capability to be valid and what permissions a capability can hold.

Most of the time, hopefully, you will not be writing assembly and so this is simply a detail for the compiler to worry about. You can think of a CHERI memory capability as a pointer that the hardware understands. In C, if you hold a pointer to an object then you are allowed to access the object that it points to. If you do some pointer arithmetic that goes out of bounds of the object, C says that this is undefined behaviour. CHERI says more concretely that it will trap: you are not authorised to access that memory with this capability. If you hold two pointers to objects that are adjacent in memory, then you may be authorised to access the memory, but not with the pointer that you are using.

This highlights the two key security principles that capability systems are able to enforce:

Capability systems make it easy to implement least privilege by providing running code only with the minimal set of capabilities (with the limited set of rights) that they need. They make it easy to implement intentionality by requiring the specific capability to be presented along with each operation. The latter avoids a large category of confused deputy attacks, where a component holding one privilege is tricked into exercising it on behalf of a differently trusted component.

In a CHERIoT system, every pointer in a higher-level language such as C, and every implicit pointer (such as the stack pointer, global pointer, and so on) used to build the language's abstractions, is a CHERI capability. If you have used other CHERI systems then you may have seen a hybrid mode, where only some pointers are capabilities and others are integers relative to an implicit capability. CHERIoT does not have this hybrid mode. The hybrid mode is intended for running legacy binaries but makes it harder to provide fine-grained sandboxing. CHERIoT assumes all code will be recompiled for the new target.

The phrase 'differently trusted' in the previous paragraph is not an attempt to extend political correctness to software components. Capability systems do not imply hierarchical trust models. Two components may hold disjoint or overlapping sets of capabilities that allow each to perform some set of actions that the other cannot. In a CHERI system, this can include one component having read access to an object and another write access, or two components having access to different fields of the same structure.

2.3. Restricting memory access with compressed bounds

The original CHERI prototypes used a 256-bit capability that stored a full 64-bit base and length. This was useful for research and prototyping but replacing 64-bit pointers with 256-bit ones was an unacceptable overhead when we started to move CHERI from research to production. Newer CHERI implementations reduce this overhead by taking advantage of redundancy. The base, top, and address of a capability all have some common bits in the top of their address.

Consider a pointer to memory location 0x08000234, in an allocation that starts at 0x08000230 and is 64 bytes long. The base, top, and address all start 0x080002, so you can store that part separately and then you just need to store the low bits for each of the three values. Modern CHERI encodings work somewhat like this. They store the address of the pointer as a full 32- or 64-bit value and then use a floating-point bounds encoding to store the distance from that value to the top and the bottom.

The floating-point representations use a shared exponent but different mantissas for the top and bottom. In the previous example, this means that you'd store the address as the full 32-bit value: 0x08000234. The top is 0x3c bytes above and the base 0x4 bytes below this address. Even on the most space-constrained CHERI encodings, these will fit entirely in the mantissa and so the exponent will be zero.

CHERIoT uses a nine-bit mantissa. If the distance to the top and base can't be expressed in nine bits then you may not be able to store a precise value. For example, imagine that you want a 1024-byte allocation. You can express this, but only if the base and top are at least four-byte aligned.

The larger a memory region you want to represent, the more strongly aligned the base and top must be. The compiler or memory allocator will handle this for you if capabilities correspond to complete allocations but this can be a problem when you are creating sub-object capabilities. For example, if you want to pass a capability to a region within a reusable buffer as a function argument, you may not be able to express the bounds precisely. When this happens, you must choose between splitting the operation into two calls that each use part of the buffer, or trusting the callee with slightly larger bounds.

2.4. Decomposing permissions in CHERIoT

Any CHERI system provides a set of permissions on capabilities. Permissions, along with bounds, are capability metadata, as shown in Figure 1. A CHERIoT capability grants access to a range of memory. CHERI systems typically use double the size of the platform's native address for capabilities, so all of the metadata needs to fit in the size of one address. As well as this metadata, there is a non-addressable tag bit, sometimes called a valid bit that differentiates between capabilities and other data. If a memory location or a register has its valid bit set, then it holds a capability and the hardware promises that this was derived from a valid sequence of operations from some more powerful capability.

A lot of capability systems, particularly software capability systems, store capabilities in tables or special memory locations. CHERI could not take this approach because it was designed to allow C implementations to use capabilities to represent pointers and C allows interleaving pointers and data. Any memory location in a C program that is large enough and sufficiently aligned to hold a pointer may hold a pointer or some other data. CHERI systems support this arbitrary interleaving with a tag bit. On a CHERIoT system, addresses are 32 bits but capabilities are 65 bits. Normal data operations see only 64 of these bits but capability operations see all 65. If you store data (for example, a 32-bit word or an 8-bit byte) somewhere in a 65-bit chunk, the data will be stored and the tag bit will be cleared. If you load a capability-sized chunk of memory into a capability register, the tag bit will be loaded along with the other 64 bits and will determine whether you've loaded a capability or just 64 bits of data. When you store this back to memory, the tag bit is propagated out again.

Tag bits and their accompanying data are moved between registers and memory atomically. This guarantees that you can't write part of a capability and some data to the same location and end up with a valid capability.

A CHERIoT capability contains an address, bounds, a type, and a set of permissions.

Figure 1. A CHERIoT capability grants access to a range of memory.

The very earliest CHERI research prototypes used a 256-bit capability on a 64-bit architecture. The versions aimed at production have all used no more than double the address size to store a capability.

Most prior CHERI systems have 64-bit addresses (and therefore 128-bit capabilities) and so have a lot of space for permissions as an orthogonal bitfield. The CHERIoT platform has 32-bit addresses (and therefore 64-bit capabilities) and so has to compress the permissions. This is done, in part, by separating the permissions into primary and dependent permissions. The primary permissions (listed in Table 1. CHERIoT primary permissions) have meaning by themselves. If you use the CHERIoT RTOS logging support (described in Chapter 9. Features for debug builds) to print capabilities, the permissions will be listed using the letters in the first column.

Table 1. CHERIoT primary permissions
Debug output letter Permission name Meaning
G Global May be stored anywhere in memory.
R Load (Read) May be used to read.
W Store (Write) May be used to write.
X Execute May be used to as a jump target (executed).
S Seal May be used to seal other capabilities (see Section 2.6. Sealing pointers for tamper proofing).
U Unseal May be used to unseal sealed capabilities.
0 User 0 Reserved for software use.

Read and write permission allow the capability to be used as an operand to load and store instructions, respectively. Execute allows the capability to be used as a jump target, where it will end up installed as the program counter capability and used for instruction fetch. We'll cover the sealing and unsealing permissions later.

Global is a bit unusual. The other permissions affect what you can do with the memory that the capability refers to, whereas global affects what you can do with this capability. This should make more sense when we look at the permissions that interact with the global permission.

The dependent permissions (listed in Table 2. CHERIoT dependent permissions) provide more fine-grained control. Dependent permissions are ones that depend on the existence of some other permission. Without that permission (or, in the case of load / store capability, at least one of the possible primary permissions), they would be meaningless.

Table 2. CHERIoT dependent permissions
Debug output letter Permission name Depends on Meaning
c Load / Store Capability R / W May be used to load or store capabilities as well as non-capability data.
g Load Global R May be used to load capabilities with the global permission.
m Load Mutable R May be used to load capabilities with write permission.
l Store Local W May be used to store capabilities that do not have global permission.
a Access System Registers X Code run via this capability may access reserved special registers.

For many of these, it's more useful to think about what can't be done if you lack the permission than to think about what can be done if you have it. By default, the load and store permissions authorise instructions to load and store non-capability data. With the load / store capability permission, they also allow loading and / or storing capabilities. Removing this permission is useful for pure-data buffers. You can't accidentally store a valid pointer into them, and if they already contain a valid pointer then no one can load it via this capability.

You can use a capability that has the load-global permission to load capabilities that have the global permission. Any capability loaded via a capability without this permission will have its global (and load-global) permission stripped. It can then be stored only via a capability that has the store-local permission.

These permissions are complex but they exist to support language-level features that are much simpler. These language-level properties work because CHERIoT RTOS provides the store-local permission exclusively to stacks and stack capabilities are not global. This combination initially guarantees thread isolation in CHERIoT. Pointers to stack allocations are derived from the stack capability, and so lack global, and can therefore be stored only on the stack (the only thing with store-local permission).

Removing the global permission from any other capability gives it the same property: you can store it only on the stack. If you pass it to another function then that function cannot store it in a global or on the heap, which gives you a shallow no-capture guarantee: The callee cannot hold onto a copy of the pointer after the end of the call. This is shallow because the callee can capture pointers to objects that are reachable via pointers stored in the original object. Removing the local-global permission makes this a deep no-capture guarantee. Any pointer loaded, at any level of indirection, from the original pointer will have the property that it can be stored only on the stack.

Similarly, store and load-mutable permissions are intended to give similar language-level guarantees for mutability. If you have a capability without store permission then you cannot use it to modify the object that the capability points to. If that object contains pointers then you may be able to load one of those and modify an object reachable from the original capability. This gives a shallow immutability. Removing the load-mutable permission turns this into a deep immutability guarantee, stripping both store and load-mutable permissions from any capability that you load. This lets you share a read-only view of a complex data structure.

The access-system-registers permission controls access to a small number of privileged registers and is never handed out to code other than a tiny trusted component in the core of the RTOS.

The CHERIoT encoding stores 12 permissions in five bits by excluding meaningless combinations and some that are not normally useful. This comes with a few limitations, most notably that execute permission implies load. It is not possbile to remove load permission from an executable capability. Some modern platforms support execute-only memory as a security feature. CHERIoT cannot express this but this does not cause practical problems for security. The sentry mechanism (described in Section 2.6. Sealing pointers for tamper proofing) lets you have memory that readable only while executing from it, which is a more useful security property. Execute-only memory normally aims to prevent information leaks that lead to code-reuse attacks. These attacks, in turn, are triggered via pointer injection or other memory-safety violations, which CHERIoT deterministically mitigates.

2.5. Building memory safety

Memory safety is a property of a source-level abstract machine. Memory safety for C, Java, or Rust mean different things. At the hardware level, CHERIoT is designed to enable implementations of languages to enforce memory safety, in the presence of untrusted code such as inline assembly or code written in a different language. Most importantly, it provides the tools that allow code in a compartment (see Section 2.8. Isolating components with threads and compartments) to protect itself from arbitrary code in a different compartment. This means protecting objects such that code from a different security context cannot:

The hardware provides tools for enforcing all of these properties but it's up to the compiler and the RTOS to cooperate to use them correctly. For example, in the CHERIoT ABI, each compartment has a single capability in a register that spans all of its globals and a single capability that grants access to its entire stack. The compiler will derive capabilities from these that are bounded to individual globals or on-stack objects. Inline assembly that references the global-pointer or stack-pointer registers directly can bypass spatial memory safety for these objects, but only from within the same compartment. None of the properties relating to heap objects make sense in the absence of a heap. CHERIoT RTOS provides a shared heap (see Chapter 8. Memory management in CHERIoT RTOS) which enforces spatial and temporal safety for heap objects.

2.6. Sealing pointers for tamper proofing

We have discussed all of the primary permissions from Table 1. CHERIoT primary permissions with the exception of those related to sealing. Sealing a capability transforms it from something that conveys rights and can be used to exercise those rights into an opaque token. It can be transformed back with the converse unseal operation.

Capabilities have one field that we have not yet discussed: an object type. THis is normally zero, representing an unsealed capability. Any non-zero value indicates a sealed capability.

When you seal a capability, you use a capability with permit-seal permission. The sealing operation sets the object type of the newly sealed capability to the address of the capability that authorised the seal operation. With a non-zero object type, the sealed capability cannot be modified. Any attempt to change the address, bounds, or permission will clear the tag and give an invalid capability. It can be copied but is always treated as an opqaue value. Unsealing is the only operation that can modify a sealed capability. This requires a valid capability with permit-seal permission and the same address as the capability that was used in the original seal operation. The unseal operation results in a capability that is identical to the one that was sealed.

If you attempt to unseal a capability that is not sealed with the value of the permit-unseal capability then you will get back an untagged value. Sealed capabilities can therefore be used as trusted handles that can be shared with untrusted code. If the untrusted code tries to modify the value in any way, you can detect the tampering, either by inspecting the tag bit after unsealing or by trying to use it and getting a trap.

Sealing is the building block for a lot of the higher-level security properties in the CHERIoT system. Being able to hand out opaque tokens that can be validated when handed back is a very powerful primitive. Sealed capabilities are a core part of the cross-compartment call mechanism as well as the building block for software-defined capabilities throughout the RTOS.

The CHERIoT encoding has space for only three bits of object type (in contrast with 'big CHERI' systems such as Morello that typically have 18 bits). This is sufficient for a small number of core parts of the ABI but not enough for general-purpose use. To mitigate this limitation, the CHERIoT memory allocator provides a set of APIs (see Section 8.7. Allocating on behalf of a caller) that virtualise the sealing mechanism. The same mechanism is also used to build software-defined capabilities.

The object type in a CHERIoT capability is interpreted differently depending on whether the sealed capability is executable or not. For executable capabilities, most of the object types are reserved for sealed entry (sentry) capabilities. A sentry capability can be unsealed automatically by jumping to it. Return addresses are automatically sealed by the jump-and-link instructions, so you cannot modify a return address, you can only jump to it.

Beyond that, return addresses are sealed as a different kind of sentry. If you substitute a return address on the stack with a function pointer (or vice versa) you will get a trap in the jump. This makes control-flow hijacking attacks very hard to mount on a CHERIoT system.

Sentries are also used as a building block for cross-compartment calls. A sentry can point to a region of memory that contains both code and data. The data is accessible via PC-relative addressing only after jumping into the code.

2.7. Controlling interrupt status with sentries

In conventional RISC-V (and most other architectures) the interrupt status is controlled via a special register. This register can be modified only in some privileged mode. The CHERIoT ISA allows it to be modified by any code running with the Access System Registers permission in the program counter capability.

Embedded software often wants to disable interrupts for short periods but granting the permission to toggle interrupts makes auditing availability guarantees between mutually distrusting components almost impossible. Instead, CHERIoT provides three kinds of sentries that control the interrupt status. These either enable or disable interrupts, or leave the interrupt enabled state untouched. The branch-and-link instruction captures the current exception state in the return sentry.

This allows you to provide function pointers to functions that will run with interrupts disabled and guarantee that, on return, the interrupt status is reset as it should be. In effect, this brings structured programming to interrupt status.

In the RTOS, for example, the atomics library provides a set of functions that (on single-core systems without hardware atomics) perform simple read-modify-write operations with interrupts disabled. A compartment can use these without having the ability to arbitrarily toggle interrupts, giving a limit on the amount of time that it can run with interrupts disabled.

2.8. Isolating components with threads and compartments

Most mainstream operating systems have a process model that evolved from mainframe systems. This is built around isolation, with sharing as an afterthought. The primary goal for process isolation was to allow consolidation, replacing multiple minicomputers with a single mainframe. These abstractions were designed with the assumption that they ran independent workloads that wanted to share computational resources. Gradually, communication mechanisms have been added on top.

CHERIoT starts from a fundamental assumption that isolation is easy, (safe) sharing is hard. Particularly in the embedded space, it's easy to provide a separate core and SRAM if you want strong isolation without sharing. Most useful workloads involve communication between distrusting entities. For example, if you want to connect an IoT device to a back-end service, your ethernet driver needs to communicate with the TCP/IP stack, which needs to communicate with the TLS stack, which needs to communicate with a higher-level protocol stack such as MQTT, which needs to communicate with your device-specific logic.

CHERIoT provides two composable abstractions for isolation.

A compartment owns some code and some globals. It exports a set of functions as entry points and may import some entry points from other compartments. A thread owns a register state and a stack and is a schedulable entity.

At any given point, the core is executing one thread in one compartment. Threads move between compartments via function call and return. When code in one compartment calls another, it loses access to everything that was not explicitly shared. Specifically:

On return, the stack becomes accessible again but a similar set of state clearing guarantees confidentiality from the callee to the caller.

Arguments that are passed from one compartment to another may include capabilities. At the start of execution, each compartment has a guarantee that nothing else can see or modify its globals. If one compartment passes a pointer to one of its globals to another, you now have shared memory. This can be useful with restricted permissions for sharing read-only epoch counters and similar.

2.9. Sharing code with libraries

Invoking reusable components does not always involve a change of security context. The CHERIoT software model provides shared libraries for sharing code without a security boundary.

Unlike compartments, shared libraries do not have mutable globals. They are reusable code and read-only data, nothing else. Because of this they are invoked via a much lighter-weight mechanism than a full cross-compartment call. This mechanism doesn't clear the stack or registers.

Using a CHERIoT shared library is conceptually equivalent to copying the code that implements it into every compartment that uses it. Unlike simple copying, shared libraries are independently auditable (see Chapter 11. Auditing firmware images) and require only a single copy of the code in memory. All entry points exported from a shared library are invoked via sentries. This means that they can enable or disable interrupts for the duration of the call.

Some shared libraries expose very simple functions, others are a lot more complex. For example, the atomics library provides some functions that are only a handful of instructions long. In contrast, the shared library that packages Microvium provides a complete JavaScript interpreter.

2.10. Auditing firmware images

When a CHERIoT firmware image starts, the loader initialises all of the capabilities that each compartment holds at boot. It does this using metadata provided by the linker. This means that everything that leads to capabilities being provided is visible to the linker. The CHERIoT linker, in addition to providing the firmware image, provides a report about this structure. The report includes:

This allows automated build time auditing of various high-level security policies. For example, you can check that a single compartment, containing a known binary (for example, one that has been approved by regulators), is the only thing that is able to access a specified device. You can require that nothing runs with interrupts disabled except a specific set of permitted library functions. Or you can say that users can provide their own logic for controlling their IoT device, but require that only compartments that you trust can have the permission to connect to your cloud servers.