Motivation
The typical modern application depends on hundreds or thousands of third-party packages, all of which execute with essentially the same authority as the application itself. If a dependency is compromised, malicious code usually inherits the application’s full filesystem and network access.
Traditional sandboxing can mitigate this, but it has three large shortcomings.
- Privileges must be shared by all packages.
- As mentioned before, if I am creating a tool like ssh, it fundamentally needs disk and network access. So even if a simple json parsing library gets compromised, it inherits all the permissions of the entire application.
- Burden of sandboxing falls to the user when it would be better borne by the developer.
- People generally don’t enforce any sandboxing on their applications. The typical naive user certainly isn’t. And even sophisticated engineering teams who may deploy using docker (or any other containerization technology) still run many system utilities and dev tools without any sandbox.
- Enforcing a compile time sandbox ensures that the sandbox is “always on”. This costs users nothing and developers very little.
- Mojo tries to make library usage easier at the cost of making library development harder based on the reasoning that most important libraries have many more users than developers. The exact same reasoning applies here.
- Traditional runtime sandboxing adds some runtime overhead.
Goals
This proposal has 3 main goals:
- The effect system implemented by this proposal should allow developers to restrict access to network/disk on a per-package basis.
- This system should be invisible. It should not add any runtime overhead (by operating entirely at compiletime) and should only add opt-in complexity for application/library developers.
- This effect system should not be overly complex or add too much additional overhead into the compilation process.
Non-Goals
- This proposal is not trying to replace traditional runtime sandboxing such as docker, wasm, or landlock. The permissions this proposal grants to packages will be broad: there are ways to get up relatively granular permissions but the goal is not to achieve feature parity with the previously mentioned examples.
- This is strictly capability restrictions, not resource regulation. For example, it can limit whether a function has access to the network, but cannot limit it to only using 100 KiB/s of network bandwidth. Mitigating any type of DoS attack is out of scope.
- This proposal is only meant to control access to things from the program’s POV. See note 2 in the effect classes section for more about this.
Proposal
The proposal is a compile-time effect system.
Effects are inferred transitively through the call graph. A function’s permission set is the union of all effects of every operation it may invoke.
Note, throughout this proposal, I will be referring to these as effects. But Mojo already has traits and treats functions as first class objects, so these effects could just as easily be traits that are automatically inferred for each function.
Note, all effects for functions will be inferred. Outside of the trusted code/stdlib (see trusted core section), developers will never need to manually annotate their functions with effects.
Effect Classes
The initial effect classes are:
fs = <none|low|high>- Any function that touches file system objects has the
fs=higheffect (see the effect levels section to see what “high” means).
- Any function that touches file system objects has the
net = <none|low|high>- Any function that touches the network has the
net=higheffect.
- Any function that touches the network has the
Filesystem and network effects use three levels:
none < low < high
Note 1: Obviously, anything that the compiler cannot reason about will get all effects (fs=high, net=high). This includes things like spawning subprocesses, FFI, inline assembly, inline MLIR, mutably accessing global variables, etc.
Note 2: All these effects are from the program’s point of view. All of the following scenarios are examples of when a package can technically access a resource it doesn’t have access to, but all of them are OK and are expected behavior. This is ok since this proposal is explicitly NOT a replacement for filesystem permissions or traditional runtime sandboxing.
- A function with
fs=high,net=nonecan still technically access the network by reading/writing from/to a file on a NFS. - On Linux, a package with
fs=high,net=nonemay still be able to access network by writing to the/dev/net/tunfile. - A package with
fs=none,net=highcan technically write to disk by sending http PUT requests to a file server running on localhost.
Effect Levels
Each of these levels mean very specific things.
none → If function f has the effect fs=none, then no operation that may be invoked by f may have anything other than fs=none.
low → If function f has the effect fs=low, then any function call that is a non-constructor method of an argument (or a field within an argument) may have fs=<any>. Any function call/operation that is not a method of an argument must have fs=none.
See the following variations of log_event() as an example.
@assert_effects(fs="high")
def log_event(fpath: String, event: String):
with open(fpath, "a") as f: # open has fs=high
f.write(event) # write has fs=high
@assert_effects(fs="low")
def log_event(fobj: File, event: String):
fobj.write(event) # write has fs=high
This allows for very granular permissions (even at compile time!). For example, foo() in the following block of code can query the database but cannot access disk or network in any other way. If we wanted we could make it even more granular by creating a wrapper struct that only allows select queries on a single table and automatically attaches where user = ... to control which rows this function can see (this is probably better done creating a DB user with the correct permissions, but this example is just to show how granular we can set permissions).
@assert_effects(fs="low", net="low")
def foo(client: PostgresClient):
# can execute queries without a problem by calling any methods of client necessary,
# but cannot touch disk or network in any other way
...
var client = PostgresClient()
foo(client)
The downside of this is that it does not mesh well with all APIs. For example python’s Path allows users to push/pop files/directories from the filepath and has a .read_bytes() and .write_bytes() method, which means that any function with fs=low and accepts Path type as an argument effectively gets all the same privileges as a fs=high function.
high → If function f has the effect fs=high, then f may invoke operations that do any arbitrary filesystem operation.
Struct methods will just treat the struct itself as a parameter of the function.
struct Logger:
var fobj: File
# fs=high
def __init__(out self, fpath: String):
self.fobj = open(fpath, "w")
# fs=none
def __init__(out self, fobj: File):
self.fobj = fobj
# fs=low (calling an fs=high function, but it's a method of one of its arguments)
def log(mut self, event: String):
self.fobj.write(event)
Trusted Core
The standard library is implemented using external calls, which would balloon each function to have all effects under my previous description. The solution to this is to treat the standard library (and ONLY the standard library) as “trusted”, so it should be allowed to tell the compiler to assume a certain function has XYZ effects.
The following decorator seems like the nicest way to do this.
@assume_effects(fs="high", net="none")
def open() -> File:
external_call["open", c_int](...) # this has all effects since mojo cannot reason about this.
Function-Level Effect Assertions (1st Interface)
After the effect system is in place, there also needs to be some way for developers to tap into this system. I propose 3 different interfaces.
Firstly, we should allow developers to specify the maximum effects a single function can have.
I think the cleanest API would be an assert_effects decorator. The compiler will infer each function’s effects as usual, and ensure that all functions marked with @assert_effects() have the effects specified (or effects with strictly lower permissions). If this isn’t the case, it will result in a compiler error.
Here’s an example
@assert_effects(
fs="high", # Unnecessary but here for completeness
net="low", # Compile error if anything in this function can touch the network.
)
fn main():
# implementation for a hypothetical mojo grep
Library-Wide Default Effects (2nd Interface)
A library author should be able to define default effect ceilings for all functions in the package.
For example:
[effects]
fs = "high"
net = "low"
When we build a package, the mojo compiler will automatically check all dependencies. If any of the exposed functions in a dependency have effects with greater permissions than the effects specified, the compiler will raise an error.
- This check could also be done once when a package is installed or updated.
- This check should also happen just before an author publishes their package.
This accomplishes three things.
- Package users can see at a glance what permissions a package has. If a math library has network access, it’s immediately suspicious.
- Developers should audit their dependencies, but we often times don’t. However, if we update a package and see that its permissions have been increased, it’s a hint that we should at least audit that one dependency.
- Package authors are protected against supply chain attacks.
- Let’s say I’m implementing sqlite in mojo and a bad actor trying to export SSH keys publishes a malicious version of the compression library I’m using. In order to export SSH keys, the bad actor needs to bump the effects to
net=high. For an sqlite library, its likely that I’d setnet=low, so I wouldn’t be able to publish the package if I call any of the compressionnet=highfunctions that perform the export.
- Let’s say I’m implementing sqlite in mojo and a bad actor trying to export SSH keys publishes a malicious version of the compression library I’m using. In order to export SSH keys, the bad actor needs to bump the effects to
Dependency Maximum Effects (3rd Interface)
Any mojo project (library or application) should also be able to define effects for each dependency. These effects will be checked against the union of all effects of all functions in this dependency that are called by our project (all functions not called by our project are not checked).
This is quite important since many large packages will have at least some functionality that uses the network and some that uses the filesystem. Take regex as an example: it’s quite reasonable for a regex library to have a function like search_file(fpath: String, pattern: Pattern). However, this requires the entire package’s effects to be bumped to fs=high just because of this function. An application user worried why a simple regex user has filesystem access can install the regex package as follows (syntax flexible). This allows the user to use 99% of the regex library while not worrying about why a regex library is touching disk.
[dependencies]
regex = {
version = "2.1",
effects = { fs = "low", net = "low" },
}
Possible Additional Effect Classes
Though this post is mainly concerned with sandboxing, having an effect system like this has many more usecases. The following two classes are what I currently have in mind for generic usage.
nondeterminism = <none|high>
- Any function that is not completely deterministic (
time.time(),random(), etc) hasnondeterminism=high) - It must be the case that
nondeterminism >= max(fs, net)(since reading from files or network is not deterministic).
sideeffect = <none|high>
- Any function that has side effects (
print(), spawning processes/threads, etc) - It must be the case that
sideeffect >= max(fs, net)(since writing to files or network is a side effect).
One example use case of these is when implementing a left-right data structure. TLDR, its a wrapper around any other arbitrary data structure. It will duplicate its in memory state and apply operations to both copies at different times which allows for a concurrent writer and many readers. Since this wrapper needs to apply a function twice, the function should ideally be deterministic and not have side effects.
Open Questions
How should compile time be handled?
Should each class of effect (net, fs) be enumerated by runtime/compiletime as well as the levels?
My gut says no. Comptime is just normal mojo code that runs at compile time, so I’d imagine the compiler should infer the same effects at compile time as it would for runtime.
Can effects be inferred after comptime code is evaluated?
# compiler infers `fs=low` if verbose is False and `fs=high` otherwise.
def foo[verbose: Bool = False](...):
comptime if verbose:
with open("some/log/file.log", "a") as fobj:
fobj.write("foo() called")
...
How do we handle writes to mutable pointers?
A write to a pointer with an external origin can have arbitrary effects and should have fs=high,net=high. However, a write to a pointer pointing at exclusively owned anonymous memory is safe. My best solution so far is just to have any write to any pointer be treated as having any arbitrary effect. The result of this is that most functions will have at least <fs/net>=low (if the pointer being written to is passed as an argument) and will never have <fs/net>=none. Maybe this is acceptable.
Summary
The proposal uses the effect system specified and provides three APIs:
- Allow any developer to specify the maximum effects/permissions a function should have.
- Allow package authors to specify default effect/permission ceilings.
- Allow package consumers to impose independent maximum effect ceilings on dependencies.
The initial effect model is intentionally small:
fs: none | low | high
net: none | low | high
# additional effects (potentially nondeterminism and sideeffect) to be added later as needed
The goal is not to determine whether arbitrary code is malicious. Instead, it ensures that code cannot exercise more external authority than its authors and consumers explicitly allowed.
For supply-chain security, that changes the consequences of a compromised dependency: replacing trusted code with malicious code no longer automatically grants the attacker the full authority of the application.
I’ve briefly mentioned some of my thoughts about this in the discord to Owen and several others. If anyone has thoughts, I’d love to hear them!