Skip to content

Commit 736e565

Browse files
committed
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
1 parent 466dfe3 commit 736e565

File tree

18 files changed

+384
-31
lines changed

18 files changed

+384
-31
lines changed

Documentation/Plugins.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ To list the plugins that are available within the context of a package, use the
6767
❯ swift package plugin --list
6868
```
6969

70-
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.
70+
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 `--allowNetworkConnections` flag can be used to allow network connections without showing a prompt.
7171

7272
## Writing a Plugin
7373

Sources/Basics/Sandbox.swift

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@
1313
import Foundation
1414
import TSCBasic
1515

16+
public enum SandboxNetworkPermission {
17+
case none
18+
case local(ports: [UInt8])
19+
case all(ports: [UInt8])
20+
21+
fileprivate var domain: String? {
22+
switch self {
23+
case .none: return nil
24+
case .local: return "local"
25+
case .all: return "*"
26+
}
27+
}
28+
29+
fileprivate var ports: [UInt8] {
30+
switch self {
31+
case .all(let ports): return ports
32+
case .local(let ports): return ports
33+
case .none: return []
34+
}
35+
}
36+
}
37+
1638
public enum Sandbox {
1739
/// Applies a sandbox invocation to the given command line (if the platform supports it),
1840
/// and returns the modified command line. On platforms that don't support sandboxing, the
@@ -27,10 +49,11 @@ public enum Sandbox {
2749
command: [String],
2850
strictness: Strictness = .default,
2951
writableDirectories: [AbsolutePath] = [],
30-
readOnlyDirectories: [AbsolutePath] = []
52+
readOnlyDirectories: [AbsolutePath] = [],
53+
allowNetworkConnections: SandboxNetworkPermission = .none
3154
) throws -> [String] {
3255
#if os(macOS)
33-
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories)
56+
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: allowNetworkConnections)
3457
return ["/usr/bin/sandbox-exec", "-p", profile] + command
3558
#else
3659
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
@@ -78,7 +101,8 @@ fileprivate let threadSafeDarwinCacheDirectories: [AbsolutePath] = {
78101
fileprivate func macOSSandboxProfile(
79102
strictness: Sandbox.Strictness,
80103
writableDirectories: [AbsolutePath],
81-
readOnlyDirectories: [AbsolutePath]
104+
readOnlyDirectories: [AbsolutePath],
105+
allowNetworkConnections: SandboxNetworkPermission
82106
) throws -> String {
83107
var contents = "(version 1)\n"
84108

@@ -95,6 +119,29 @@ fileprivate func macOSSandboxProfile(
95119
// This is needed to launch any processes.
96120
contents += "(allow process*)\n"
97121

122+
if let domain = allowNetworkConnections.domain {
123+
// this is used by the system for caching purposes and will lead to log spew if not allowed
124+
contents += "(allow file-write* (regex \"/Users/*/Library/Caches/*/Cache.db*\"))"
125+
126+
// this allows the specific network connections, as well as resolving DNS
127+
contents += """
128+
(system-network)
129+
(allow network-outbound
130+
(literal "/private/var/run/mDNSResponder")
131+
"""
132+
133+
allowNetworkConnections.ports.forEach { port in
134+
contents += "(remote ip \"\(domain):\(port)\")"
135+
}
136+
137+
// empty list of ports means all are permitted
138+
if allowNetworkConnections.ports.isEmpty {
139+
contents += "(remote ip \"\(domain):*\")"
140+
}
141+
142+
contents += "\n)\n"
143+
}
144+
98145
// The following accesses are only needed when interpreting the manifest (versus running a compiled version).
99146
if strictness == .manifest_pre_53 {
100147
// This is required by the Swift compiler.

Sources/Commands/PackageTools/PluginCommand.swift

Lines changed: 94 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import ArgumentParser
14+
import Basics
1415
import CoreCommands
1516
import Dispatch
1617
import PackageGraph
@@ -38,6 +39,15 @@ struct PluginCommand: SwiftCommand {
3839
@Option(name: .customLong("allow-writing-to-directory"),
3940
help: "Allow the plugin to write to an additional directory")
4041
var additionalAllowedWritableDirectories: [String] = []
42+
43+
enum NetworkPermission: String, EnumerableFlag, ExpressibleByArgument {
44+
case none
45+
case local
46+
case all
47+
}
48+
49+
@Option(name: .customLong("allow-network-connections"))
50+
var allowNetworkConnections: NetworkPermission = .none
4151
}
4252

4353
@OptionGroup()
@@ -116,38 +126,64 @@ struct PluginCommand: SwiftCommand {
116126
// 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.
117127
let outputDir = pluginsDir.appending(component: "outputs")
118128

129+
var allowNetworkConnections = SandboxNetworkPermission.init(options.allowNetworkConnections)
119130
// Determine the set of directories under which plugins are allowed to write. We always include the output directory.
120131
var writableDirectories = [outputDir]
121132
if options.allowWritingToPackageDirectory {
122133
writableDirectories.append(package.path)
123134
}
124-
else {
125-
// If the plugin requires write permission but it wasn't provided, we ask the user for approval.
126-
if case .command(_, let permissions) = plugin.capability {
127-
for case PluginPermission.writeToPackageDirectory(let reason) in permissions {
128-
let problem = "Plugin ‘\(plugin.name)’ wants permission to write to the package directory."
129-
let reason = "Stated reason: “\(reason)”."
130-
if swiftTool.outputStream.isTTY {
131-
// We can ask the user directly, so we do so.
132-
let query = "Allow this plugin to write to the package directory?"
133-
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
134-
swiftTool.outputStream.flush()
135-
let answer = readLine(strippingNewline: true)
136-
// Throw an error if we didn't get permission.
137-
if answer?.lowercased() != "yes" {
138-
throw ValidationError("Plugin was denied permission to write to the package directory.")
139-
}
140-
// Otherwise append the directory to the list of allowed ones.
141-
writableDirectories.append(package.path)
142-
}
143-
else {
144-
// We can't ask the user, so emit an error suggesting passing the flag.
145-
let remedy = "Use `--allow-writing-to-package-directory` to allow this."
146-
throw ValidationError([problem, reason, remedy].joined(separator: "\n"))
135+
136+
// If the plugin requires permissions, we ask the user for approval.
137+
if case .command(_, let permissions) = plugin.capability {
138+
try permissions.forEach {
139+
let permissionString: String
140+
let reasonString: String
141+
let remedyOption: String
142+
143+
switch $0 {
144+
case .writeToPackageDirectory(let reason):
145+
guard !options.allowWritingToPackageDirectory else { return } // permission already granted
146+
permissionString = "write to the package directory"
147+
reasonString = reason
148+
remedyOption = "--allow-writing-to-package-directory"
149+
case .allowNetworkConnections(let scope, let reason):
150+
guard scope != .none else { return } // no need to prompt
151+
guard options.allowNetworkConnections != scope else { return } // permission already granted
152+
let portsString = scope.ports.isEmpty ? "on all ports" : "on ports: \(scope.ports)"
153+
permissionString = "allow \(scope.label) network connections \(portsString)"
154+
reasonString = reason
155+
remedyOption = "--allow-network-connections \(scope.label)"
156+
}
157+
158+
let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)."
159+
let reason = "Stated reason: “\(reasonString)”."
160+
if swiftTool.outputStream.isTTY {
161+
// We can ask the user directly, so we do so.
162+
let query = "Allow this plugin to \(permissionString)?"
163+
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
164+
swiftTool.outputStream.flush()
165+
let answer = readLine(strippingNewline: true)
166+
// Throw an error if we didn't get permission.
167+
if answer?.lowercased() != "yes" {
168+
throw StringError("Plugin was denied permission to \(permissionString).")
147169
}
170+
} else {
171+
// We can't ask the user, so emit an error suggesting passing the flag.
172+
let remedy = "Use `\(remedyOption)` to allow this."
173+
throw StringError([problem, reason, remedy].joined(separator: "\n"))
174+
}
175+
176+
switch $0 {
177+
case .writeToPackageDirectory:
178+
// Otherwise append the directory to the list of allowed ones.
179+
writableDirectories.append(package.path)
180+
case .allowNetworkConnections(let scope, _):
181+
allowNetworkConnections = .init(scope)
182+
break
148183
}
149184
}
150185
}
186+
151187
for pathString in options.additionalAllowedWritableDirectories {
152188
writableDirectories.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
153189
}
@@ -187,6 +223,7 @@ struct PluginCommand: SwiftCommand {
187223
accessibleTools: accessibleTools,
188224
writableDirectories: writableDirectories,
189225
readOnlyDirectories: readOnlyDirectories,
226+
allowNetworkConnections: allowNetworkConnections,
190227
pkgConfigDirectories: swiftTool.options.locations.pkgConfigDirectories,
191228
fileSystem: swiftTool.fileSystem,
192229
observabilityScope: swiftTool.observabilityScope,
@@ -223,3 +260,37 @@ extension PluginCommandIntent {
223260
}
224261
}
225262
}
263+
264+
extension SandboxNetworkPermission {
265+
init(_ scope: PluginNetworkPermissionScope) {
266+
switch scope {
267+
case .none: self = .none
268+
case .local(let ports): self = .local(ports: ports)
269+
case .all(let ports): self = .all(ports: ports)
270+
}
271+
}
272+
}
273+
274+
extension PluginCommand.PluginOptions.NetworkPermission {
275+
public static func == (lhs: PluginCommand.PluginOptions.NetworkPermission, rhs: PluginNetworkPermissionScope) -> Bool {
276+
switch rhs {
277+
case .none: return lhs == .none
278+
case .local: return lhs == .local
279+
case .all: return lhs == .all
280+
}
281+
}
282+
283+
public static func != (lhs: PluginCommand.PluginOptions.NetworkPermission, rhs: PluginNetworkPermissionScope) -> Bool {
284+
return !(lhs == rhs)
285+
}
286+
}
287+
288+
extension SandboxNetworkPermission {
289+
init(_ permission: PluginCommand.PluginOptions.NetworkPermission) {
290+
switch permission {
291+
case .none: self = .none
292+
case .local: self = .local(ports: [])
293+
case .all: self = .all(ports: [])
294+
}
295+
}
296+
}

Sources/Commands/PackageTools/SwiftPackageTool.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ extension SwiftPackageTool {
125125
else if matchingPlugins.count > 1 {
126126
throw ValidationError("\(matchingPlugins.count) plugins found for '\(command)'")
127127
}
128-
128+
129129
// At this point we know we found exactly one command plugin, so we run it.
130130
try PluginCommand.run(
131131
plugin: matchingPlugins[0],

Sources/Commands/Utilities/DescribedPackage.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,34 @@ struct DescribedPackage: Encodable {
186186
}
187187

188188
struct Permission: Encodable {
189+
enum NetworkScope: Encodable {
190+
case none
191+
case local(ports: [UInt8])
192+
case all(ports: [UInt8])
193+
194+
init(_ scope: PluginNetworkPermissionScope) {
195+
switch scope {
196+
case .none: self = .none
197+
case .local(let ports): self = .local(ports: ports)
198+
case .all(let ports): self = .all(ports: ports)
199+
}
200+
}
201+
}
202+
189203
let type: String
190204
let reason: String
205+
let networkScope: NetworkScope
191206

192207
init(from permission: PackageModel.PluginPermission) {
193208
switch permission {
194209
case .writeToPackageDirectory(let reason):
195210
self.type = "writeToPackageDirectory"
196211
self.reason = reason
212+
self.networkScope = .none
213+
case .allowNetworkConnections(let scope, let reason):
214+
self.type = "allowNetworkConnections"
215+
self.reason = reason
216+
self.networkScope = .init(scope)
197217
}
198218
}
199219
}

Sources/PackageDescription/PackageDescription.docc/Curation/PluginPermission.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Create a Permission
66

7+
- ``allowNetworkConnections(scope:reason:)``
78
- ``writeToPackageDirectory(reason:)``
89

910
### Encoding and Decoding

Sources/PackageDescription/PackageDescriptionSerialization.swift

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,33 @@ extension PluginCommandIntent: Encodable {
298298
}
299299
}
300300

301+
extension PluginNetworkPermissionScope: Encodable {
302+
public func encode(to encoder: Encoder) throws {
303+
var container = encoder.singleValueContainer()
304+
switch self {
305+
case .all: try container.encode("all")
306+
case .local: try container.encode("local")
307+
case .none: try container.encode("none")
308+
}
309+
}
310+
311+
var ports: [UInt8] {
312+
switch self {
313+
case .all(let ports): return ports
314+
case .local(let ports): return ports
315+
case .none: return []
316+
}
317+
}
318+
}
319+
301320
/// `Encodable` conformance.
302321
extension PluginPermission: Encodable {
303322
private enum CodingKeys: CodingKey {
304-
case type, reason
323+
case type, reason, scope, ports
305324
}
306325

307326
private enum PermissionType: String, Encodable {
327+
case allowNetworkConnections
308328
case writeToPackageDirectory
309329
}
310330

@@ -314,6 +334,11 @@ extension PluginPermission: Encodable {
314334
public func encode(to encoder: Encoder) throws {
315335
var container = encoder.container(keyedBy: CodingKeys.self)
316336
switch self {
337+
case ._allowNetworkConnections(let scope, let reason):
338+
try container.encode(PermissionType.allowNetworkConnections, forKey: .type)
339+
try container.encode(reason, forKey: .reason)
340+
try container.encode(scope, forKey: .scope)
341+
try container.encode(scope.ports, forKey: .ports)
317342
case ._writeToPackageDirectory(let reason):
318343
try container.encode(PermissionType.writeToPackageDirectory, forKey: .type)
319344
try container.encode(reason, forKey: .reason)

0 commit comments

Comments
 (0)