Apple introduced Swift Package Plugin, a new SPM feature, at WWDC 22 last year. With the Swift Package Plugin, developers can extend the menu items and build process in Xcode to customize and automate some of the development process.
We know that Apple deprecated the previously unconstrained third-party plugin mechanism in Xcode 8 with a new extension mechanism called Xcode Extensions. All extensions run in their own separate processes and cannot tamper with the behavior of the main Xcode program. This is Apple’s ancestral art, and of course greatly improves the security of third-party extensions.
Last year’s Swift Package Plugin gave Xcode a different kind of extension, so I was curious about what the limitations were and what developers could do with it, and this article will discuss the Command Plugin.
First Swift Package Plugin
To add a plugin to an existing package is very simple. First you create a
Plugins directory, create a directory with the same name as the plugin in it, and then you can write the specific code files. At this point the directory structure is as follows.
Package.swift and add the following to the
This is all configured, wait for the package to resolve again, then you can see our plugin in the project’s right-click menu.
Swift Package Plugin is not much different from a normal CLI program, we need to declare entry functions for the plugin. Here we use the module
PackagePlugin and implement the
CommandPlugin protocol, which conforms to the Type-Based Program Entry Points. The code is as follows.
Once the plugin is running,
arguments are the command line arguments that Xcode passes in when it calls us. In
context we get the complete, parsed package information and the working directory where the plugin is currently running. Here we try to write a temporary file to the plugin’s working directory.
After running it, you can see that
test.txt has been created. When we change the target path and write a file to the desktop directory, the plugin will throw an exception.
At this point, we can actually tentatively conclude that the Swift Package Plugin is running in a sandboxed environment, and that file reading and writing is restricted. I later verified that this is true in Activity Monitor.
Verify the available permissions for the Swift Package plugin
Use the Network framework to access a local service at
POSIXErrorCode: Operation not permitted, thus verifying that the network cannot be accessed.
Create child processes
Process to run the
git version 2.36.1, thus verifying that child processes can be created.
Access to system services
Simply tested a few basic services and all the above operations failed.
Start the local calculator app as a derived child process. The calculator process is killed by the
SIGILL signal (crashes at runtime).
The mechanism of macOS Sandbox
Similar to iOS, macOS provides process sandboxing support at the kernel level, allowing precise control over the permissions of each sandboxed process (e.g. file access, Mach IPC, and other system calls). Unlike iOS, macOS provides a command line tool
sandbox-exec to expose the sandboxing capabilities to the user. With
sandbox-exec and a profile file describing permissions, we can execute arbitrary processes in a custom sandbox environment.
sandbox-execis quite widely used, for example by Bazel to implement sandboxed builds to ensure stability of build products and determinism of input and output dependencies.
Of course, in addition to
sandbox-exec in the user state, we can also use the Sandbox API (
sandbox.h) to perform sandbox-related operations, and
sandbox-exec is also based on the Sandbox API +
execvp in its implementation.
Here we focus on the profile file required by
sandbox-exec. Under the system directory
/System/Library/Sandbox/Profiles you can see many
*.sb files, which are Sandbox profiles.
Let’s check one at random.
Sandbox Profile is written in the SBPL language, which has a very Lisp-like syntax and is relatively easy to read. The syntax and API of the Sandbox Profile can be found in this PDF, which is a very complete introduction.
The core operations of the Sandbox Profile are
deny, which are two methods with parameters that are both operations and filters (optional). For example, the statement
(allow signal (target self)) means: allow execution of an operation that sends a signal and whose target is itself**. For some strict runtime environments, we can also use
(deny default) to disable all operations and then whitelist the required operations using the
We can also use wildcards to control a set of operations, for example the statement
(deny file-write*) will disable all operations prefixed with
It is worth noting that Sandbox is inherited process-wise, i.e., the parent process passes its Sandbox state to all child processes derived from it. This property is also very well understood. If a process derives a child process that can escape the sandbox, then the parent process is also equivalent to indirectly escaping the sandbox. If so, the parent process controls the child processes outside the sandbox through a pipeline, the sandbox mechanism is completely useless.
In macOS, a sandboxed application can be launched in non-sandboxed mode with
NSWorkspace.open(_:). This is a deliberate “back door” because Apple understands that this is a manageable situation - after all, as a desktop device, the Mac is more permissive than a mobile device like the iPhone. Does this violate Sandbox’s process model? It doesn’t.
open(1) or other similar ways of launching applications rely on Launch Services, a system service provided by the
launchd process. The application interacts with
launchd through the Mach IPC. The application is eventually launched by
launchd, which “escapes the sandbox” (in fact, in terms of process relationships, this “child process” is parented to
launchd, which does not conflict with the Sandbox process model).
Swift Package Plugin startup process analysis
We now know that the Swift Package Plugin is running in a sandbox environment, but we don’t know its exact profile yet. So here I will extract the Sandbox Profile of the Swift Package Plugin by reverse analyzing its startup process.
The first step is to find an entry point. Since the Xcode code is becoming increasingly large, it is difficult to quickly locate the logic that starts the Swift Package Plugin by static analysis alone, so I am going to use a dynamic analysis approach here. First of all, to start a process, it is usually done through
posix_spawn system calls. So here we start by intercepting these syscalls with
After some experimentation, I found the
posix_spawnsyscall to be the one used, and I’ll skip the rest of the experimentation here.
Get the following stack.
Here we get the launch logic for the Swift Package Plugin, which looks like the upper-level API uses
NSTask. For extracting the Sandbox Profile, we just need to get the startup parameters for
Place a breakpoint in the LLDB.
Check the running variables after breaking.
You can see that the plugin’s runtime environment disables all permissions by default, and
(import "system.sb") turns on only a few permissions necessary for system processes, not including the Mach IPC for any file read/write and any namespace, and then adds a few restricted file read/write operations and process operations to make it easier to modify files in the plugin or use subprocesses (like Git, where some operations are file I/O only).
The reason why the above attempt to start the calculator failed was not because it could not spawn a process, but because the calculator process could not create an
NSWindow, a process that requires establishing a
CGSConnectionID with the
WindowServer. Since the plug-in process does not have lookup access to its namespace, it cannot find the Mach Port and communicate with it.
The same is true for other system services that cannot be used. Most of the system services are provided by daemon processes called
xxxxxxd, and clients communicate with the services through the Mach Port to use the capabilities they provide. The system frameworks actually encapsulate these communications into High-Level APIs for developers.
This article has briefly introduced the Swift Package Plugin and explored what it can do. As you can see, the plugin is still very limited in what it can do due to the limitations of the sandbox environment. But it’s true that this is in line with Apple’s usual style of providing the ability to extend the system or an application in a restricted and controlled environment. Last year iPadOS also got the ability to load a three-way driver, quite unexpectedly, but predictably this driver is also based on
DriverKits restricted environment, and does not have the ability to interact directly with the kernel.
But I believe that the Swift Package Plugin we saw last year must not be its ultimate form, just like SwiftUI, we can see it become open and flexible little by little.