From 83d7b3dcbb44d7b9f0f621db37ee3cbb12f83a04 Mon Sep 17 00:00:00 2001 From: Boris Buegling Date: Thu, 2 Feb 2023 18:23:23 -0800 Subject: [PATCH] Let command plugins ask for network permissions This adds a new plugin permission that allows a command plugin to ask for networking permissions. The permission can distinguish between local and outgoing connections, as well as specifying a list or range of ports to allow. Similar to existing permissions, there's also a CLI option for allowing connections. resolves #5489 --- Documentation/Plugins.md | 2 +- Sources/Basics/Sandbox.swift | 70 +++++++++- .../Commands/PackageTools/PluginCommand.swift | 128 +++++++++++++++--- .../PackageTools/SwiftPackageTool.swift | 2 +- .../Commands/Utilities/DescribedPackage.swift | 24 ++++ .../Curation/PluginPermission.md | 1 + .../PackageDescriptionSerialization.swift | 29 +++- Sources/PackageDescription/Target.swift | 41 +++++- .../PackageLoading/ManifestJSONParser.swift | 9 ++ .../Manifest/TargetDescription.swift | 20 +++ .../ManifestSourceGeneration.swift | 21 +++ Sources/PackageModel/Target.swift | 39 ++++++ Sources/SPMBuildCore/PluginInvocation.swift | 3 + Sources/SPMBuildCore/PluginScriptRunner.swift | 1 + .../Workspace/DefaultPluginScriptRunner.swift | 11 +- Tests/CommandsTests/PackageToolTests.swift | 103 ++++++++++++++ .../PluginInvocationTests.swift | 1 + .../ManifestSourceGenerationTests.swift | 13 ++ 18 files changed, 488 insertions(+), 30 deletions(-) diff --git a/Documentation/Plugins.md b/Documentation/Plugins.md index 94f412331fa..6164f7c8055 100644 --- a/Documentation/Plugins.md +++ b/Documentation/Plugins.md @@ -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. ## Writing a Plugin diff --git a/Sources/Basics/Sandbox.swift b/Sources/Basics/Sandbox.swift index b57013a3f2b..d3369ee0213 100644 --- a/Sources/Basics/Sandbox.swift +++ b/Sources/Basics/Sandbox.swift @@ -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 @@ -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. @@ -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" @@ -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. diff --git a/Sources/Commands/PackageTools/PluginCommand.swift b/Sources/Commands/PackageTools/PluginCommand.swift index 8e9637c67bd..f0237c05161 100644 --- a/Sources/Commands/PackageTools/PluginCommand.swift +++ b/Sources/Commands/PackageTools/PluginCommand.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import Basics import CoreCommands import Dispatch import PackageGraph @@ -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() @@ -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).") } + } 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)) } @@ -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, @@ -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 + } + } +} diff --git a/Sources/Commands/PackageTools/SwiftPackageTool.swift b/Sources/Commands/PackageTools/SwiftPackageTool.swift index ddf42a1e129..c50a4d26d86 100644 --- a/Sources/Commands/PackageTools/SwiftPackageTool.swift +++ b/Sources/Commands/PackageTools/SwiftPackageTool.swift @@ -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], diff --git a/Sources/Commands/Utilities/DescribedPackage.swift b/Sources/Commands/Utilities/DescribedPackage.swift index cc1002d0ce3..5a884210f5c 100644 --- a/Sources/Commands/Utilities/DescribedPackage.swift +++ b/Sources/Commands/Utilities/DescribedPackage.swift @@ -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) } } } diff --git a/Sources/PackageDescription/PackageDescription.docc/Curation/PluginPermission.md b/Sources/PackageDescription/PackageDescription.docc/Curation/PluginPermission.md index bb9ed3cdbd8..aaa0fdad43a 100644 --- a/Sources/PackageDescription/PackageDescription.docc/Curation/PluginPermission.md +++ b/Sources/PackageDescription/PackageDescription.docc/Curation/PluginPermission.md @@ -4,6 +4,7 @@ ### Create a Permission +- ``allowNetworkConnections(scope:reason:)`` - ``writeToPackageDirectory(reason:)`` ### Encoding and Decoding diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index b80515558fd..3d12e95e6e5 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -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 } @@ -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) diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 037fae4389b..a9147ac775a 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1221,12 +1221,39 @@ public extension PluginCommandIntent { /// The type of permission a plugin requires. /// -/// Only one type of permission is supported. See ``writeToPackageDirectory(reason:)``. +/// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. @available(_PackageDescription, introduced: 5.6) public enum PluginPermission { + @available(_PackageDescription, introduced: 5.9) + case _allowNetworkConnections(scope: PluginNetworkPermissionScope, reason: String) case _writeToPackageDirectory(reason: String) } +/// The scope of a network permission. This can be none, local connections only or all connections. +@available(_PackageDescription, introduced: 5.9) +public enum PluginNetworkPermissionScope { + /// Do not allow network access. + case none + /// Allow local network connections, can be limited to a list of allowed ports. + case local(ports: [UInt8] = []) + /// Allow local and outgoing network connections, can be limited to a list of allowed ports. + case all(ports: [UInt8] = []) + /// Allow connections to Docker through unix domain sockets. + case docker + /// Allow connections to any unix domain socket. + case unixDomainSocket + + /// Allow local and outgoing network connections, limited to a range of allowed ports. + public static func all(ports: Range) -> PluginNetworkPermissionScope { + return .all(ports: Array(ports)) + } + + /// Allow local network connections, limited to a range of allowed ports. + public static func local(ports: Range) -> PluginNetworkPermissionScope { + return .local(ports: Array(ports)) + } +} + @available(_PackageDescription, introduced: 5.6) public extension PluginPermission { /// Create a permission to modify files in the package's directory. @@ -1239,6 +1266,18 @@ public extension PluginPermission { static func writeToPackageDirectory(reason: String) -> PluginPermission { return _writeToPackageDirectory(reason: reason) } + + /// Create a permission to make network connections. + /// + /// The command plugin wants permission to make network connections. The `reason` string is shown + /// to the user at the time of request for approval, explaining why the plugin is requesting this access. + /// - Parameter scope: The scope of the permission. + /// - Parameter reason: A reason why the permission is needed. This will be shown to the user. + /// - Returns: A `PluginPermission` instance. + @available(_PackageDescription, introduced: 5.9) + static func allowNetworkConnections(scope: PluginNetworkPermissionScope, reason: String) -> PluginPermission { + return _allowNetworkConnections(scope: scope, reason: reason) + } } extension Target.PluginUsage { diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 5df2cc4e8b6..854e72057ca 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -765,6 +765,15 @@ extension TargetDescription.PluginPermission { fileprivate init(v4 json: JSON) throws { let type = try json.get(String.self, forKey: "type") switch type { + case "allowNetworkConnections": + let reason = try json.get(String.self, forKey: "reason") + let scope = try json.get(String.self, forKey: "scope") + let ports = try json.get([Int].self, forKey: "ports").map { UInt8($0) } + if let scope = TargetDescription.PluginNetworkPermissionScope(scope, ports: ports) { + self = .allowNetworkConnections(scope: scope, reason: reason) + } else { + throw JSON.MapError.custom(key: "scope", message: "invalid scope '\(scope)'") + } case "writeToPackageDirectory": let reason = try json.get(String.self, forKey: "reason") self = .writeToPackageDirectory(reason: reason) diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index d01a9ea6366..6c4a8ea63bf 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -116,7 +116,27 @@ public struct TargetDescription: Equatable, Encodable, Sendable { case custom(verb: String, description: String) } + public enum PluginNetworkPermissionScope: Equatable, Codable, Sendable { + case none + case local(ports: [UInt8]) + case all(ports: [UInt8]) + case docker + case unixDomainSocket + + public init?(_ scopeString: String, ports: [UInt8]) { + switch scopeString { + case "none": self = .none + case "local": self = .local(ports: ports) + case "all": self = .all(ports: ports) + case "docker": self = .docker + case "unix-socket": self = .unixDomainSocket + default: return nil + } + } + } + public enum PluginPermission: Equatable, Codable, Sendable { + case allowNetworkConnections(scope: PluginNetworkPermissionScope, reason: String) case writeToPackageDirectory(reason: String) } diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 4c090410ba5..0ef51686b86 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -456,9 +456,30 @@ fileprivate extension SourceCodeFragment { } } + init(from networkPermissionScope: TargetDescription.PluginNetworkPermissionScope) { + switch networkPermissionScope { + case .none: + self.init(enum: "none") + case .local(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "local", subnodes: [ports]) + case .all(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "all", subnodes: [ports]) + case .docker: + self.init(enum: "docker") + case .unixDomainSocket: + self.init(enum: "unixDomainSocket") + } + } + /// Instantiates a SourceCodeFragment to represent a single plugin permission. init(from permission: TargetDescription.PluginPermission) { switch permission { + case .allowNetworkConnections(let scope, let reason): + let scope = SourceCodeFragment(key: "scope", subnode: .init(from: scope)) + let reason = SourceCodeFragment(key: "reason", string: reason) + self.init(enum: "allowNetworkConnections", subnodes: [scope, reason]) case .writeToPackageDirectory(let reason): let param = SourceCodeFragment(key: "reason", string: reason) self.init(enum: "writeToPackageDirectory", subnodes: [param]) diff --git a/Sources/PackageModel/Target.swift b/Sources/PackageModel/Target.swift index 05e29640e0e..42750dd3f3c 100644 --- a/Sources/PackageModel/Target.swift +++ b/Sources/PackageModel/Target.swift @@ -814,11 +814,50 @@ public enum PluginCommandIntent: Hashable, Codable { } } +public enum PluginNetworkPermissionScope: Hashable, Codable { + case none + case local(ports: [UInt8]) + case all(ports: [UInt8]) + case docker + case unixDomainSocket + + init(_ scope: TargetDescription.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 + } + } + + public var label: String { + switch self { + case .all: return "all" + case .local: return "local" + case .none: return "none" + case .docker: return "docker unix domain socket" + case .unixDomainSocket: return "unix domain socket" + } + } + + public var ports: [UInt8] { + switch self { + case .all(let ports): return ports + case .local(let ports): return ports + case .none, .docker, .unixDomainSocket: return [] + } + } +} + public enum PluginPermission: Hashable, Codable { + case allowNetworkConnections(scope: PluginNetworkPermissionScope, reason: String) case writeToPackageDirectory(reason: String) public init(from desc: TargetDescription.PluginPermission) { switch desc { + case .allowNetworkConnections(let scope, let reason): + self = .allowNetworkConnections(scope: .init(scope), reason: reason) case .writeToPackageDirectory(let reason): self = .writeToPackageDirectory(reason: reason) } diff --git a/Sources/SPMBuildCore/PluginInvocation.swift b/Sources/SPMBuildCore/PluginInvocation.swift index 4eab2515899..d463ab13855 100644 --- a/Sources/SPMBuildCore/PluginInvocation.swift +++ b/Sources/SPMBuildCore/PluginInvocation.swift @@ -58,6 +58,7 @@ extension PluginTarget { accessibleTools: [String: (path: AbsolutePath, triples: [String]?)], writableDirectories: [AbsolutePath], readOnlyDirectories: [AbsolutePath], + allowNetworkConnections: [SandboxNetworkPermission], pkgConfigDirectories: [AbsolutePath], fileSystem: FileSystem, observabilityScope: ObservabilityScope, @@ -271,6 +272,7 @@ extension PluginTarget { workingDirectory: workingDirectory, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnections, fileSystem: fileSystem, observabilityScope: observabilityScope, callbackQueue: callbackQueue, @@ -471,6 +473,7 @@ extension PackageGraph { accessibleTools: accessibleTools, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: [], pkgConfigDirectories: pkgConfigDirectories, fileSystem: fileSystem, observabilityScope: observabilityScope, diff --git a/Sources/SPMBuildCore/PluginScriptRunner.swift b/Sources/SPMBuildCore/PluginScriptRunner.swift index 8269ed4754d..e31a3d2b8ca 100644 --- a/Sources/SPMBuildCore/PluginScriptRunner.swift +++ b/Sources/SPMBuildCore/PluginScriptRunner.swift @@ -50,6 +50,7 @@ public protocol PluginScriptRunner { workingDirectory: AbsolutePath, writableDirectories: [AbsolutePath], readOnlyDirectories: [AbsolutePath], + allowNetworkConnections: [SandboxNetworkPermission], fileSystem: FileSystem, observabilityScope: ObservabilityScope, callbackQueue: DispatchQueue, diff --git a/Sources/Workspace/DefaultPluginScriptRunner.swift b/Sources/Workspace/DefaultPluginScriptRunner.swift index a90ce0630be..c10a15fd094 100644 --- a/Sources/Workspace/DefaultPluginScriptRunner.swift +++ b/Sources/Workspace/DefaultPluginScriptRunner.swift @@ -49,6 +49,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { workingDirectory: AbsolutePath, writableDirectories: [AbsolutePath], readOnlyDirectories: [AbsolutePath], + allowNetworkConnections: [SandboxNetworkPermission], fileSystem: FileSystem, observabilityScope: ObservabilityScope, callbackQueue: DispatchQueue, @@ -74,6 +75,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { workingDirectory: workingDirectory, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnections, initialMessage: initialMessage, observabilityScope: observabilityScope, callbackQueue: callbackQueue, @@ -405,6 +407,7 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { workingDirectory: AbsolutePath, writableDirectories: [AbsolutePath], readOnlyDirectories: [AbsolutePath], + allowNetworkConnections: [SandboxNetworkPermission], initialMessage: Data, observabilityScope: ObservabilityScope, callbackQueue: DispatchQueue, @@ -422,7 +425,13 @@ public struct DefaultPluginScriptRunner: PluginScriptRunner, Cancellable { // Optionally wrap the command in a sandbox, which places some limits on what it can do. In particular, it blocks network access and restricts the paths to which the plugin can make file system changes. It does allow writing to temporary directories. if self.enableSandbox { do { - command = try Sandbox.apply(command: command, strictness: .writableTemporaryDirectory, writableDirectories: writableDirectories + [self.cacheDir], readOnlyDirectories: readOnlyDirectories) + command = try Sandbox.apply( + command: command, + strictness: .writableTemporaryDirectory, + writableDirectories: writableDirectories + [self.cacheDir], + readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnections + ) } catch { return callbackQueue.async { completion(.failure(error)) diff --git a/Tests/CommandsTests/PackageToolTests.swift b/Tests/CommandsTests/PackageToolTests.swift index e0dbdd203f3..60e6252bdab 100644 --- a/Tests/CommandsTests/PackageToolTests.swift +++ b/Tests/CommandsTests/PackageToolTests.swift @@ -1852,6 +1852,109 @@ final class PackageToolTests: CommandsTestCase { } } + func testCommandPluginNetworkingPermissions(permissionsManifestFragment: String, permissionError: String, reason: String, remedy: [String]) throws { + // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). + try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") + + try testWithTemporaryDirectory { tmpPath in + // Create a sample package with a library target and a plugin. + let packageDir = tmpPath.appending(components: "MyPackage") + try localFileSystem.writeFileContents(packageDir.appending(components: "Package.swift")) { + $0 <<< """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "MyPackage", + targets: [ + .target(name: "MyLibrary"), + .plugin(name: "MyPlugin", capability: .command(intent: .custom(verb: "Network", description: "Help description"), permissions: \(permissionsManifestFragment))), + ] + ) + """ + } + try localFileSystem.writeFileContents(packageDir.appending(components: "Sources", "MyLibrary", "library.swift")) { + $0 <<< """ + public func Foo() { } + """ + } + try localFileSystem.writeFileContents(packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift")) { + $0 <<< """ + import PackagePlugin + + @main + struct MyCommandPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) throws { + print("hello world") + } + } + """ + } + + #if os(macOS) + do { + let result = try SwiftPMProduct.SwiftPackage.executeProcess(["plugin", "Network"], packagePath: packageDir) + XCTAssertNotEqual(result.exitStatus, .terminated(code: 0)) + XCTAssertNoMatch(try result.utf8Output(), .contains("hello world")) + XCTAssertMatch(try result.utf8stderrOutput(), .contains("error: Plugin ‘MyPlugin’ wants permission to allow \(permissionError).")) + XCTAssertMatch(try result.utf8stderrOutput(), .contains("Stated reason: “\(reason)”.")) + XCTAssertMatch(try result.utf8stderrOutput(), .contains("Use `\(remedy.joined(separator: " "))` to allow this.")) + } + #endif + + // Check that we don't get an error (and also are allowed to write to the package directory) if we pass `--allow-writing-to-package-directory`. + do { + let result = try SwiftPMProduct.SwiftPackage.executeProcess(["plugin"] + remedy + ["Network"], packagePath: packageDir) + XCTAssertEqual(result.exitStatus, .terminated(code: 0)) + XCTAssertMatch(try result.utf8Output(), .contains("hello world")) + } + } + } + + func testCommandPluginNetworkingPermissions() throws { + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .all(), reason: \"internet good\")]", + permissionError: "all network connections on all ports", + reason: "internet good", + remedy: ["--allow-network-connections", "all"]) + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .all(ports: [23, 42]), reason: \"internet good\")]", + permissionError: "all network connections on ports: 23, 42", + reason: "internet good", + remedy: ["--allow-network-connections", "all"]) + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .all(ports: 1..<4), reason: \"internet good\")]", + permissionError: "all network connections on ports: 1, 2, 3", + reason: "internet good", + remedy: ["--allow-network-connections", "all"]) + + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .local(), reason: \"localhost good\")]", + permissionError: "local network connections on all ports", + reason: "localhost good", + remedy: ["--allow-network-connections", "local"]) + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .local(ports: [23, 42]), reason: \"localhost good\")]", + permissionError: "local network connections on ports: 23, 42", + reason: "localhost good", + remedy: ["--allow-network-connections", "local"]) + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .local(ports: 1..<4), reason: \"localhost good\")]", + permissionError: "local network connections on ports: 1, 2, 3", + reason: "localhost good", + remedy: ["--allow-network-connections", "local"]) + + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .docker, reason: \"docker good\")]", + permissionError: "docker unix domain socket connections", + reason: "docker good", + remedy: ["--allow-network-connections", "docker"]) + try testCommandPluginNetworkingPermissions( + permissionsManifestFragment: "[.allowNetworkConnections(scope: .unixDomainSocket, reason: \"unix sockets good\")]", + permissionError: "unix domain socket connections", + reason: "unix sockets good", + remedy: ["--allow-network-connections", "unixDomainSocket"]) + } + func testCommandPluginPermissions() throws { // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") diff --git a/Tests/SPMBuildCoreTests/PluginInvocationTests.swift b/Tests/SPMBuildCoreTests/PluginInvocationTests.swift index e88a98cb9c6..0315a8cba35 100644 --- a/Tests/SPMBuildCoreTests/PluginInvocationTests.swift +++ b/Tests/SPMBuildCoreTests/PluginInvocationTests.swift @@ -118,6 +118,7 @@ class PluginInvocationTests: XCTestCase { workingDirectory: AbsolutePath, writableDirectories: [AbsolutePath], readOnlyDirectories: [AbsolutePath], + allowNetworkConnections: [SandboxNetworkPermission], fileSystem: FileSystem, observabilityScope: ObservabilityScope, callbackQueue: DispatchQueue, diff --git a/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift b/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift index 46ea4cdabff..fa3a55706e4 100644 --- a/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift +++ b/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift @@ -527,4 +527,17 @@ class ManifestSourceGenerationTests: XCTestCase { """ try testManifestWritingRoundTrip(manifestContents: manifestContents, toolsVersion: .v5_8) } + + func testPluginNetworkingPermissionGeneration() throws { + let manifest = Manifest.createRootManifest( + name: "thisPkg", + path: .init(path: "/thisPkg"), + toolsVersion: .v5_9, + dependencies: [], + targets: [ + try TargetDescription(name: "MyPlugin", type: .plugin, pluginCapability: .command(intent: .custom(verb: "foo", description: "bar"), permissions: [.allowNetworkConnections(scope: .all(ports: [23, 42]), reason: "internet good")])) + ]) + let contents = try manifest.generateManifestFileContents(packageDirectory: manifest.path.parentDirectory) + try testManifestWritingRoundTrip(manifestContents: contents, toolsVersion: .v5_9) + } }