[Pitch] SwiftPM Plugins - Explicit BuildTool Sandbox Permissions

Introduction

Since there are multiple topics (1, 2) where people have stated interest in network access for Swift Package Manager Plugins, I wanted to bring them together and draft a first pitch for a discussion.

Motivation

In the aforementioned discussions we have seen multiple projects which had to rely on workarounds or do not function at all without explicit network permission.

  • tevelee is distributing modular binary xcframeworks to clients. For inter-module communication they rely on SPIs, where private swiftinterface files are stored in a remote artifact storage. They'd like to retrieve these SPIs as part of the build process of their package.
  • 0xTim is deploying swift apps to AWS/GCP/Heroku with a Swift Plugin.
  • hassila mentioned wanting to download static resources for processing from the internet as part of the build process.
  • joshw came up with the idea to pull an OpenAPI specification and generate Swift types for it using a plugin.
  • Personally, I created a Kotlin Multiplatform plugin for SPM so that it would run gradle and assemble the xcframework as part of the package building process. This is useful because a) it allows for code sharing between Android and iOS (business logic, local data storage, network access) and b) increases developer ergonomy for Swift developers relying on shared code, since they can now build the entire stack from Xcode. However, gradle requires to access the internet to pull binaries and artifacts.

Some of these use-cases were possible using Xcode 14, if you employed a .prebuildCommand instead of a regular .buildCommand. Apparently the sandbox was not 100 % airtight, which has (unfortunately) been fixed with Xcode 15.

Proposal

Extension of SwiftPM Plugin Definition

Swift Package Manager already offers an API to explicitly specify plugin permissions for .command plugins, we could reuse this pattern extending it to .buildTool().

case command(
    intent: PluginCommandIntent,
    permissions: [PluginPermission] = []
)

// new buildTool
case buildTool(
    intent: PluginBuildIntent,
    permissions: [PluginBuildToolPermission] = []
)

I am proposing to explicitly duplicate PluginCommandIntent and PluginPermission, retaining the original names for compatibility reasons. Through duplication we can allow for the permissions and intents to diverge, which may be the case given the different use cases for command and build tool plugins.

enum PluginBuildIntent {
    case documentationGeneration
    case sourceCodeFormatting
    case custom(description: String)
}

The PluginBuildToolPermission features two permissions, which I perceived as desirable from the initial discussions. The access to the package folder is explicitly excluded here, since build commands already have a way of accessing the package folder indirectly through build outputs. The allowNetworkConnections permission relies on the original PluginNetworkPermissionScope which I found handy to reuse here. It already offers a great way to describe network access with convenience functions, that in my opinion also fit in nicely here.

enum PluginBuildToolPermission {
    case allowNetworkConnections(
        scope: PluginNetworkPermissionScope,
        reason: String
    )

    case allowFileSystemAccess(
        scope: PluginFileSystemPermissionScope,
        reason: String
    )
}

The PluginFileSystemPermissionScope offers a granular way to specify access to a specific path and a read or readWrite permission. Additionally, we may think of an explicit file execution permission.

struct PluginFileSystemPermissionScope {
    /// The path that permissions are requested for.
    let path: Path

    /// Specifies the permissions requested for the path.
    let permission: Permission
}

extension PluginFileSystemPermissionScope {
    enum Permission {
        case read
        case readWrite
    }
}

The extension provides quick access to default folders and a way to add custom paths.

extension PluginFileSystemPermissionScope {

    /// Requests permissions for the user's home folder.
    static func userHome(
        permission: Permission
    ) -> PluginFileSystemPermissionScope { }

    /// Requests permissions for a custom path.
    static func path(
        _ path: Path,
        permission: Permission
    ) -> PluginFileSystemPermissionScope { }

    // Potentially add more default folders.
}

SwiftPM integration

From my perspective, SwiftPM's Sandbox already offers everything we need to configure a sandbox according to the proposed design for build commands.

enum Sandbox {
    public static func apply(
        command: [String],
        fileSystem: FileSystem,
        strictness: Strictness = .default,
        writableDirectories: [AbsolutePath] = [],
        readOnlyDirectories: [AbsolutePath] = [],
        allowNetworkConnections: [SandboxNetworkPermission] = []
    )
}

The implementation effort should be limited to constructing the public api and making use of existing functionality to compose the correct function call to the Sandbox.apply function.

Xcode integration

Xcode already asks for permission when running custom user command plugins. Xcode may reuse this pattern and ask for permission listing all requested permissions when the user first runs the plugin as part of a build started.

Backwards compatibility

Since these changes are likely to be introduced with a new swift tools version, paired with a new version of Xcode, there are no additional requirements to ensure backwards compatibility.

Open discussion points

Design with multiple PluginFileSystemPermissionScopes

I am not entirely confident in the design of PluginFileSystemPermissionScope. While it roughly resembles its cousin PluginNetworkPermissionScope, it makes for awkward syntax in the permissions: [PluginBuildToolPermission] array if a plugin requests access to more than one folder:

let package = Package(
    name: "AssetMover",
    products: [.plugin(name: "AssetMover", targets: ["AssetMover"])],
    targets: [
        .plugin(
            name: "AssetMover",
            capability: .buildTool(
                intent: .custom(description: "AssetMover moves xcasset catalogs"),
                permissions: [
                    .allowFileSystemAccess(
                        scope: .userHome(permission: .read),
                        reason: "Read asset files in the home directory"
                    ), 
                    .allowFileSystemAccess(
                        scope: .path("/tmp/", permission: .readWrite),
                        reason: "Write asset files in the temp directory"
                    ), 
                ]
            )
        ),
    ]
)

Alternatively, one could think about combining scopes of different paths, however the reason would have to move along with the path or be stated as one reason for all scopes.

File execution permission

Adding to the PluginFileSystemPermissionScope permissions, we may want to add an additional execution permission for folders outside the sandbox to further elevate security. Since permissions are now no longer mutually exclusive, we may have to convert the enum to an OptionSet struct.

extension PluginFileSystemPermissionScope {
    struct Permission: OptionSet {
        let rawValue: Int

        /// Read only permission.
        static let read: Permission

        /// Write permission. Implicitly grants read as well.
        static let write: Permission

        /// Execution permission.
        static let execute: Permission
    }
}

Predefined values

The predefined values in PluginFileSystemPermissionScope and PluginBuildToolIntent are merely examples. If this discussion results in an SE proposal, we will have to compile a list of convenience functions and predefined values.

Permissions

Are file system access and network access permissions everything we require as of now? Are there other permissions you can think of?

Known workarounds

Disabling the sandbox

According to this post, you can disable the sandbox entirely by setting the appropriate Xcode defaults property.

defaults write com.apple.dt.Xcode IDEPackageSupportDisablePluginExecutionSandbox -bool YES

For obvious reasons, this cannot be a desirable solution for the above motivation.

Invoking a shortcut

You can create a shortcut in the shortcuts app that does the desired operation for you and invoke the shortcut from within the plugin execution through shortcuts run and provide appropriate parameters. You may have to work around file system restrictions of the sandbox to retrieve the shortcut execution's results.

However, this solution will only work on computers with the respective shortcut installed. Shortcut distribution and manual setup is a prerequisite for this.

12 Likes

First of all I'm all for more plugin goodness!

I'm using a plugin build tool to generate localized resources and it seems impossible de produce files at the root of the bundle. (I'm guessing to prevent conflicts, which is fair enough)

I'm not sure if this is something that would need permission or just a missing capability.

1 Like
  1. Not sure about use cases for the file permissions. Read/execute is already granted to pretty much all files, isn't it? Not entirely sure what arbitrary writes during the build process gives us, the only way to really affect the build would be producing files included by it which is already possible today.

  2. For networking permissions, I'll defer to @tomerd. My impression was that it was intentional that build tool plugins wouldn't be able to express permissions, but maybe that's just because the filesystem permissions weren't really applicable to them (see 1).

  3. I think some thought needs to be put into figuring out the UX of accepting permissions. Today, there's only one set of permissions by design since you're running a singular plugin, but this will mean there can be many permissions for a particular build. What does that mean for the CLI flags that accept permissions, it seems like we need per-plugin acceptance? How does the prompting change to incorporate this? Do I get many prompts or just one? It seems easy for a new permission to get lost here, rendering the entire idea of asking for permissions pointless, so I think we need to tread carefully.

4 Likes

1

I agree. Initially I thought you might want to download files to a specific folder for processing, however you are given permissions to write to temporary folders which should be more than enough.

2

It was intentional indeed, however I find the reasoning in the original proposal leaves room to reconsider.

SE-303
Package plugins will be run in a sandbox that prevents network access and restricts writing to the file system to specific intermediate directories. This is the same sandbox that manifest parsing uses, except that it allows writing to a limited set of output directories.
In addition, SwiftPM and IDEs that use libSwiftPM should run each command generated by an plugin in a sandbox. This sandbox should prevent all network access, and should only allow writing to the directories specified as outputs by the plugin.
There is inherent risk in running build tools provided by other packages. This can possibly be mitigated by requiring the root package to list the approved package plugins, no matter where they are in the graph. This requires further consideration, and would be a subject of a future proposal.
Source

While there is risk associated with running build tools, I do not see how this risk extends beyond use of any other swift package as part of e.g. a command line tool you are building or even installing build tools like Swiftlint through brew. As developers, we should be well aware of the risks and it is a conscious decision to take them, inspect the source, compare checksums or take additional precautions.

I feel the current approach is severly limiting the use cases of build tool plugins while at the same time not being mindful of the workarounds developers take that either have similar or worse security implications: Running a custom shell script (in a target build phase) that does something similar to a plugin that you would otherwise have written or worse disabling the sandbox altogether through defaults.

3

I agree, thats a really good point. Thanks for bringing it up. Do you know anyone we might be able to loop in who has experience here?

2 Likes

@tomerd @NeoNacho We found a workaround for the time being, however this topic still is a pain point for us. Others seem to run into this issue frequently, too. Is there something I can do to move this topic forward?

2 Likes

cc @Max_Desiatov @bnbarham since I do not work on SwiftPM anymore.