Preface

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

Project configuration

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
MyAwesomePackage
├── Package.swift
├── Plugins
│   └── Test
│       └── plugin.swift
├── README.md
├── Sources
│   └── MyLibrary
│       └── MyLibrary.swift
└── Tests
    └── MyLibraryTests
        └── MyLibraryTests.swif

Then modify Package.swift and add the following to the targets array.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let package = Package(
    // ...
    targets: [
        .plugin(
            name: "Test",
            capability: .command(
                intent: .custom(verb: "test", description: "My first plugin"),
                permissions: []))
    ]
)

This is all configured, wait for the package to resolve again, then you can see our plugin in the project’s right-click menu.

Code writing

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.

1
2
3
4
5
6
7
8
import PackagePlugin

@main
struct Test: CommandPlugin {
    func performCommand(context: PluginContext, arguments: [String]) async throws {
        // Do your work here...
    }
}

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.

1
2
3
4
func performCommand(context: PluginContext, arguments: [String]) async throws {
    let temporaryFilePath = context.pluginWorkDirectory.appending(subpath: "test.txt")
    try! "hello".write(toFile: temporaryFilePath.string, atomically: true, encoding: .utf8)
}

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.

1
Test/plugin.swift:20: Fatal error: 'try!' expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=513 "You don't have permission to save the file "test.txt" in the folder "tmp"." UserInfo={NSFilePath=/var/tmp/test.txt, NSUnderlyingError=0x6000004f9d10 {Error Domain=NSPOSIXErrorDomain Code=1 "Operation not permitted"}}

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

Network access

Use the Network framework to access a local service at localhost:3000.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func performCommand(context: PluginContext, arguments: [String]) async throws {
    let conn = NWConnection(to: .hostPort(host: .ipv4(.loopback), port: 3000), using: .tcp)
    conn.stateUpdateHandler = {
        print($0)
    }
    conn.start(queue: .main)
    let _: Void = try await withUnsafeThrowingContinuation { cont in
        conn.send(content: "hello".data(using: .utf8), completion: .contentProcessed({ error in
            if let error {
                cont.resume(with: .failure(error))
            } else {
                cont.resume()
            }
        }))
    }
}

The output POSIXErrorCode: Operation not permitted, thus verifying that the network cannot be accessed.

Create child processes

Use Process to run the git command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func performCommand(context: PluginContext, arguments: [String]) async throws {
    let process = Process()
    process.executableURL = .init(FilePath("/usr/local/bin/git"))
    process.arguments = ["--version"]

    let pipe = Pipe()
    let stdout = pipe.fileHandleForReading
    process.standardOutput = pipe

    try! process.run()
    process.waitUntilExit()
    print(String(data: try! stdout.readToEnd()!, encoding: .utf8)!)
}

Output git version 2.36.1, thus verifying that child processes can be created.

Access to system services

1
2
3
4
5
6
7
8
9
func performCommand(context: PluginContext, arguments: [String]) async throws {
    let pboard = NSPasteboard.general
    pboard.clearContents()
    pboard.setString(context.pluginWorkDirectory.string, forType: .string)

    let workspace = NSWorkspace.shared
    print(workspace.runningApplications)
    print(workspace.open(.init(string: "https://apple.com")!))
}

Simply tested a few basic services and all the above operations failed.

GUI

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

sandbox-exec(1)

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-exec is 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.

Sandbox Profile

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
(version 1)

(debug deny)

(import "system.sb")

;; allow processes to traverse symlinks
(allow file-read-metadata)

(allow file-read-data file-write-data
  (regex
    ; Allow files accessed by system dylibs and frameworks
    #"/\.CFUserTextEncoding$"
    #"^/usr/share/nls/"
    #"^/usr/share/zoneinfo /var/db/timezone/zoneinfo/"
  ))

(allow ipc-posix-shm (ipc-posix-name "apple.shm.notification_center")) ; Libnotify

(allow signal (target self))

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 allow and 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 allow method.

We can also use wildcards to control a set of operations, for example the statement (deny file-write*) will disable all operations prefixed with file-write.

Process Model

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 open(1) or 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 fork + exec* or posix_spawn system calls. So here we start by intercepting these syscalls with dtrace.

After some experimentation, I found the posix_spawn syscall to be the one used, and I’ll skip the rest of the experimentation here.

1
sudo dtrace -n 'syscall::posix_spawn:entry/pid == 79228/ { ustack(); }'

Get the following stack.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CPU     ID                    FUNCTION:NAME
  6    655                posix_spawn:entry
              libsystem_kernel.dylib`__posix_spawn+0xa
              Foundation`-[NSConcreteTask launchWithDictionary:error:]+0xe97
              SwiftPM`specialized DefaultPluginScriptRunner.invoke(compiledExec:workingDirectory:writableDirectories:readOnlyDirectories:initialMessage:observabilityScope:callbackQueue:delegate:completion:)+0xb1f
              SwiftPM`closure #1 in DefaultPluginScriptRunner.runPluginScript(sourceFiles:pluginName:initialMessage:toolsVersion:workingDirectory:writableDirectories:readOnlyDirectories:fileSystem:observabilityScope:callbackQueue:delegate:completion:)+0x431
              SwiftPM`partial apply for closure #1 in DefaultPluginScriptRunner.runPluginScript(sourceFiles:pluginName:initialMessage:toolsVersion:workingDirectory:writableDirectories:readOnlyDirectories:fileSystem:observabilityScope:callbackQueue:delegate:completion:)+0x52
              SwiftPM`partial apply for closure #4 in DefaultPluginScriptRunner.compilePluginScript(sourceFiles:pluginName:toolsVersion:observabilityScope:callbackQueue:completion:)+0x59
              SwiftPM`thunk for @escaping @callee_guaranteed () -> ()+0x19
              libdispatch.dylib`_dispatch_call_block_and_release+0xc
              libdispatch.dylib`_dispatch_client_callout+0x8
              libdispatch.dylib`_dispatch_continuation_pop+0x1cc
              libdispatch.dylib`_dispatch_async_redirect_invoke+0x2cc
              libdispatch.dylib`_dispatch_root_queue_drain+0x157
              libdispatch.dylib`_dispatch_worker_thread2+0xa0
              libsystem_pthread.dylib`_pthread_wqthread+0x100
              libsystem_pthread.dylib`start_wqthread+0xf

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 sandbox-exec.

Place a breakpoint in the LLDB.

1
breakpoint set -n "-[NSConcreteTask launchWithDictionary:error:]"

Check the running variables after breaking.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
* thread #22, queue = 'swift.org.swiftpm.shared.concurrent', stop reason = breakpoint 3.1
    frame #0: 0x00007ff81b68763c Foundation`-[NSConcreteTask launchWithDictionary:error:]
Foundation`-[NSConcreteTask launchWithDictionary:error:]:
->  0x7ff81b68763c <+0>: pushq  %rbp
    0x7ff81b68763d <+1>: movq   %rsp, %rbp
    0x7ff81b687640 <+4>: pushq  %r15
    0x7ff81b687642 <+6>: pushq  %r14
Target 0: (Xcode) stopped.

(lldb) po $arg1
<NSConcreteTask: 0x600027b5e990>

(lldb) po [$arg1 arguments]
<Swift.__SwiftDeferredNSArray 0x600009f8c580>(
-p,
(version 1)
(deny default)
(import "system.sb")
(allow file-read*)
(allow process*)
(allow file-write*
    (subpath "/private/tmp")
    (subpath "/private/var/folders/18/rdgw2vgx4g3g_1qvr7fwfhwh0000gp/T")
)
(deny file-write*
    (subpath "/private/var/tmp/redacted/MyLibrary")
)
(allow file-write*
    (subpath "/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins/Test.output")
    (subpath "/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins")
)
,
/Users/redacted/Library/Developer/Xcode/DerivedData/MyLibrary-dmdwxwcobwwasxgztgazmbufnwcy/SourcePackages/plugins/Test
)

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.

Summary

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.

Ref

  • https://unixzii.github.io/spm-plugin-n-sandbox/