Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Let command plugins ask for network permissions #6114

Merged
merged 1 commit into from
Feb 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Documentation/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ To list the plugins that are available within the context of a package, use the
❯ swift package plugin --list
```

Command plugins that need to write to the file system will cause SwiftPM to ask the user for approval if `swift package` is invoked from a console, or deny the request if it is not. Passing the `--allow-writing-to-package-directory` flag to the `swift package` invocation will allow the request without questions — this is particularly useful in a Continuous Integration environment.
Command plugins that need to write to the file system will cause SwiftPM to ask the user for approval if `swift package` is invoked from a console, or deny the request if it is not. Passing the `--allow-writing-to-package-directory` flag to the `swift package` invocation will allow the request without questions — this is particularly useful in a Continuous Integration environment. Similarly, the `--allow-network-connections` flag can be used to allow network connections without showing a prompt.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this change also be mentioned in the changelog?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 this is a great feature we should mention in chnagelog and release notes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in #6132


## Writing a Plugin

Expand Down
70 changes: 67 additions & 3 deletions Sources/Basics/Sandbox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@
import Foundation
import TSCBasic

public enum SandboxNetworkPermission: Equatable {
case none
case local(ports: [UInt8])
case all(ports: [UInt8])
case docker
case unixDomainSocket

fileprivate var domain: String? {
switch self {
case .none, .docker, .unixDomainSocket: return nil
case .local: return "local"
case .all: return "*"
}
}

fileprivate var ports: [UInt8] {
switch self {
case .all(let ports): return ports
case .local(let ports): return ports
case .none, .docker, .unixDomainSocket: return []
}
}
}

public enum Sandbox {
/// Applies a sandbox invocation to the given command line (if the platform supports it),
/// and returns the modified command line. On platforms that don't support sandboxing, the
Expand All @@ -27,10 +51,11 @@ public enum Sandbox {
command: [String],
strictness: Strictness = .default,
writableDirectories: [AbsolutePath] = [],
readOnlyDirectories: [AbsolutePath] = []
readOnlyDirectories: [AbsolutePath] = [],
allowNetworkConnections: [SandboxNetworkPermission] = []
) throws -> [String] {
#if os(macOS)
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories)
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: allowNetworkConnections)
return ["/usr/bin/sandbox-exec", "-p", profile] + command
#else
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
Expand Down Expand Up @@ -78,7 +103,8 @@ fileprivate let threadSafeDarwinCacheDirectories: [AbsolutePath] = {
fileprivate func macOSSandboxProfile(
strictness: Sandbox.Strictness,
writableDirectories: [AbsolutePath],
readOnlyDirectories: [AbsolutePath]
readOnlyDirectories: [AbsolutePath],
allowNetworkConnections: [SandboxNetworkPermission]
) throws -> String {
var contents = "(version 1)\n"

Expand All @@ -95,6 +121,44 @@ fileprivate func macOSSandboxProfile(
// This is needed to launch any processes.
contents += "(allow process*)\n"

if allowNetworkConnections.filter({ $0 != .none }).isEmpty == false {
// this is used by the system for caching purposes and will lead to log spew if not allowed
contents += "(allow file-write* (regex \"/Users/*/Library/Caches/*/Cache.db*\"))"

// this allows the specific network connections, as well as resolving DNS
contents += """
(system-network)
(allow network-outbound
(literal "/private/var/run/mDNSResponder")
"""

allowNetworkConnections.forEach {
if let domain = $0.domain {
$0.ports.forEach { port in
contents += "(remote ip \"\(domain):\(port)\")"
}

// empty list of ports means all are permitted
if $0.ports.isEmpty {
contents += "(remote ip \"\(domain):*\")"
}
}

switch $0 {
case .docker:
// specifically allow Docker by basename of the socket
contents += "(remote unix-socket (regex \"*/docker.sock\"))"
case .unixDomainSocket:
// this allows unix domain sockets
contents += "(remote unix-socket)"
default:
break
}
}

contents += "\n)\n"
}

// The following accesses are only needed when interpreting the manifest (versus running a compiled version).
if strictness == .manifest_pre_53 {
// This is required by the Swift compiler.
Expand Down
128 changes: 106 additions & 22 deletions Sources/Commands/PackageTools/PluginCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//===----------------------------------------------------------------------===//

import ArgumentParser
import Basics
import CoreCommands
import Dispatch
import PackageGraph
Expand Down Expand Up @@ -38,6 +39,17 @@ struct PluginCommand: SwiftCommand {
@Option(name: .customLong("allow-writing-to-directory"),
help: "Allow the plugin to write to an additional directory")
var additionalAllowedWritableDirectories: [String] = []

enum NetworkPermission: String, EnumerableFlag, ExpressibleByArgument {
case none
case local
case all
case docker
case unixDomainSocket
}

@Option(name: .customLong("allow-network-connections"))
var allowNetworkConnections: NetworkPermission = .none
}

@OptionGroup()
Expand Down Expand Up @@ -116,38 +128,73 @@ struct PluginCommand: SwiftCommand {
// The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc.
let outputDir = pluginsDir.appending(component: "outputs")

var allowNetworkConnections = [SandboxNetworkPermission.init(options.allowNetworkConnections)]
// Determine the set of directories under which plugins are allowed to write. We always include the output directory.
var writableDirectories = [outputDir]
if options.allowWritingToPackageDirectory {
writableDirectories.append(package.path)
}
else {
// If the plugin requires write permission but it wasn't provided, we ask the user for approval.
if case .command(_, let permissions) = plugin.capability {
for case PluginPermission.writeToPackageDirectory(let reason) in permissions {
let problem = "Plugin ‘\(plugin.name)’ wants permission to write to the package directory."
let reason = "Stated reason: “\(reason)”."
if swiftTool.outputStream.isTTY {
// We can ask the user directly, so we do so.
let query = "Allow this plugin to write to the package directory?"
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
swiftTool.outputStream.flush()
let answer = readLine(strippingNewline: true)
// Throw an error if we didn't get permission.
if answer?.lowercased() != "yes" {
throw ValidationError("Plugin was denied permission to write to the package directory.")
}
// Otherwise append the directory to the list of allowed ones.
writableDirectories.append(package.path)

// If the plugin requires permissions, we ask the user for approval.
if case .command(_, let permissions) = plugin.capability {
try permissions.forEach {
let permissionString: String
let reasonString: String
let remedyOption: String

switch $0 {
case .writeToPackageDirectory(let reason):
guard !options.allowWritingToPackageDirectory else { return } // permission already granted
permissionString = "write to the package directory"
reasonString = reason
remedyOption = "--allow-writing-to-package-directory"
case .allowNetworkConnections(let scope, let reason):
guard scope != .none else { return } // no need to prompt
guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted

switch scope {
case .all, .local:
let portsString = scope.ports.isEmpty ? "on all ports" : "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))"
permissionString = "allow \(scope.label) network connections \(portsString)"
case .docker, .unixDomainSocket:
permissionString = "allow \(scope.label) connections"
case .none:
permissionString = "" // should not be reached
}
else {
// We can't ask the user, so emit an error suggesting passing the flag.
let remedy = "Use `--allow-writing-to-package-directory` to allow this."
throw ValidationError([problem, reason, remedy].joined(separator: "\n"))

reasonString = reason
remedyOption = "--allow-network-connections \(PluginCommand.PluginOptions.NetworkPermission.init(scope).defaultValueDescription)"
}

let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)."
let reason = "Stated reason: “\(reasonString)”."
if swiftTool.outputStream.isTTY {
// We can ask the user directly, so we do so.
let query = "Allow this plugin to \(permissionString)?"
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
swiftTool.outputStream.flush()
let answer = readLine(strippingNewline: true)
// Throw an error if we didn't get permission.
if answer?.lowercased() != "yes" {
throw StringError("Plugin was denied permission to \(permissionString).")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I changed these to StringError to avoid having usage be printed to the console which seems to be a behavior of ArgumentParser's ValidationError.

}
} else {
// We can't ask the user, so emit an error suggesting passing the flag.
let remedy = "Use `\(remedyOption)` to allow this."
throw StringError([problem, reason, remedy].joined(separator: "\n"))
}

switch $0 {
case .writeToPackageDirectory:
// Otherwise append the directory to the list of allowed ones.
writableDirectories.append(package.path)
case .allowNetworkConnections(let scope, _):
allowNetworkConnections.append(.init(scope))
break
}
}
}

for pathString in options.additionalAllowedWritableDirectories {
writableDirectories.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
}
Expand Down Expand Up @@ -187,6 +234,7 @@ struct PluginCommand: SwiftCommand {
accessibleTools: accessibleTools,
writableDirectories: writableDirectories,
readOnlyDirectories: readOnlyDirectories,
allowNetworkConnections: allowNetworkConnections,
pkgConfigDirectories: swiftTool.options.locations.pkgConfigDirectories,
fileSystem: swiftTool.fileSystem,
observabilityScope: swiftTool.observabilityScope,
Expand Down Expand Up @@ -223,3 +271,39 @@ extension PluginCommandIntent {
}
}
}

extension SandboxNetworkPermission {
init(_ scope: PluginNetworkPermissionScope) {
switch scope {
case .none: self = .none
case .local(let ports): self = .local(ports: ports)
case .all(let ports): self = .all(ports: ports)
case .docker: self = .docker
case .unixDomainSocket: self = .unixDomainSocket
}
}
}

extension PluginCommand.PluginOptions.NetworkPermission {
fileprivate init(_ scope: PluginNetworkPermissionScope) {
switch scope {
case .unixDomainSocket: self = .unixDomainSocket
case .docker: self = .docker
case .none: self = .none
case .all: self = .all
case .local: self = .local
}
}
}

extension SandboxNetworkPermission {
init(_ permission: PluginCommand.PluginOptions.NetworkPermission) {
switch permission {
case .none: self = .none
case .local: self = .local(ports: [])
case .all: self = .all(ports: [])
case .docker: self = .docker
case .unixDomainSocket: self = .unixDomainSocket
}
}
}
2 changes: 1 addition & 1 deletion Sources/Commands/PackageTools/SwiftPackageTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ extension SwiftPackageTool {
else if matchingPlugins.count > 1 {
throw ValidationError("\(matchingPlugins.count) plugins found for '\(command)'")
}

// At this point we know we found exactly one command plugin, so we run it.
try PluginCommand.run(
plugin: matchingPlugins[0],
Expand Down
24 changes: 24 additions & 0 deletions Sources/Commands/Utilities/DescribedPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,38 @@ struct DescribedPackage: Encodable {
}

struct Permission: Encodable {
enum NetworkScope: Encodable {
case none
case local(ports: [UInt8])
case all(ports: [UInt8])
case docker
case unixDomainSocket

init(_ scope: PluginNetworkPermissionScope) {
switch scope {
case .none: self = .none
case .local(let ports): self = .local(ports: ports)
case .all(let ports): self = .all(ports: ports)
case .docker: self = .docker
case .unixDomainSocket: self = .unixDomainSocket
}
}
}

let type: String
let reason: String
let networkScope: NetworkScope

init(from permission: PackageModel.PluginPermission) {
switch permission {
case .writeToPackageDirectory(let reason):
self.type = "writeToPackageDirectory"
self.reason = reason
self.networkScope = .none
case .allowNetworkConnections(let scope, let reason):
self.type = "allowNetworkConnections"
self.reason = reason
self.networkScope = .init(scope)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Create a Permission

- ``allowNetworkConnections(scope:reason:)``
- ``writeToPackageDirectory(reason:)``

### Encoding and Decoding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,13 +298,35 @@ extension PluginCommandIntent: Encodable {
}
}

extension PluginNetworkPermissionScope: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .all: try container.encode("all")
case .local: try container.encode("local")
case .none: try container.encode("none")
case .docker: try container.encode("docker")
case .unixDomainSocket: try container.encode("unix-socket")
}
}

var ports: [UInt8] {
switch self {
case .all(let ports): return ports
case .local(let ports): return ports
case .none, .docker, .unixDomainSocket: return []
}
}
}

/// `Encodable` conformance.
extension PluginPermission: Encodable {
private enum CodingKeys: CodingKey {
case type, reason
case type, reason, scope, ports
}

private enum PermissionType: String, Encodable {
case allowNetworkConnections
case writeToPackageDirectory
}

Expand All @@ -314,6 +336,11 @@ extension PluginPermission: Encodable {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case ._allowNetworkConnections(let scope, let reason):
try container.encode(PermissionType.allowNetworkConnections, forKey: .type)
try container.encode(reason, forKey: .reason)
try container.encode(scope, forKey: .scope)
try container.encode(scope.ports, forKey: .ports)
case ._writeToPackageDirectory(let reason):
try container.encode(PermissionType.writeToPackageDirectory, forKey: .type)
try container.encode(reason, forKey: .reason)
Expand Down
Loading