Skip to content

Commit 00ac2ad

Browse files
authored
Let command plugins ask for network permissions (#6114)
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 d422e72 commit 00ac2ad

File tree

18 files changed

+488
-30
lines changed

18 files changed

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

7272
## Writing a Plugin
7373

Sources/Basics/Sandbox.swift

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

16+
public enum SandboxNetworkPermission: Equatable {
17+
case none
18+
case local(ports: [UInt8])
19+
case all(ports: [UInt8])
20+
case docker
21+
case unixDomainSocket
22+
23+
fileprivate var domain: String? {
24+
switch self {
25+
case .none, .docker, .unixDomainSocket: return nil
26+
case .local: return "local"
27+
case .all: return "*"
28+
}
29+
}
30+
31+
fileprivate var ports: [UInt8] {
32+
switch self {
33+
case .all(let ports): return ports
34+
case .local(let ports): return ports
35+
case .none, .docker, .unixDomainSocket: return []
36+
}
37+
}
38+
}
39+
1640
public enum Sandbox {
1741
/// Applies a sandbox invocation to the given command line (if the platform supports it),
1842
/// and returns the modified command line. On platforms that don't support sandboxing, the
@@ -27,10 +51,11 @@ public enum Sandbox {
2751
command: [String],
2852
strictness: Strictness = .default,
2953
writableDirectories: [AbsolutePath] = [],
30-
readOnlyDirectories: [AbsolutePath] = []
54+
readOnlyDirectories: [AbsolutePath] = [],
55+
allowNetworkConnections: [SandboxNetworkPermission] = []
3156
) throws -> [String] {
3257
#if os(macOS)
33-
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories)
58+
let profile = try macOSSandboxProfile(strictness: strictness, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: allowNetworkConnections)
3459
return ["/usr/bin/sandbox-exec", "-p", profile] + command
3560
#else
3661
// rdar://40235432, rdar://75636874 tracks implementing sandboxes for other platforms.
@@ -78,7 +103,8 @@ fileprivate let threadSafeDarwinCacheDirectories: [AbsolutePath] = {
78103
fileprivate func macOSSandboxProfile(
79104
strictness: Sandbox.Strictness,
80105
writableDirectories: [AbsolutePath],
81-
readOnlyDirectories: [AbsolutePath]
106+
readOnlyDirectories: [AbsolutePath],
107+
allowNetworkConnections: [SandboxNetworkPermission]
82108
) throws -> String {
83109
var contents = "(version 1)\n"
84110

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

124+
if allowNetworkConnections.filter({ $0 != .none }).isEmpty == false {
125+
// this is used by the system for caching purposes and will lead to log spew if not allowed
126+
contents += "(allow file-write* (regex \"/Users/*/Library/Caches/*/Cache.db*\"))"
127+
128+
// this allows the specific network connections, as well as resolving DNS
129+
contents += """
130+
(system-network)
131+
(allow network-outbound
132+
(literal "/private/var/run/mDNSResponder")
133+
"""
134+
135+
allowNetworkConnections.forEach {
136+
if let domain = $0.domain {
137+
$0.ports.forEach { port in
138+
contents += "(remote ip \"\(domain):\(port)\")"
139+
}
140+
141+
// empty list of ports means all are permitted
142+
if $0.ports.isEmpty {
143+
contents += "(remote ip \"\(domain):*\")"
144+
}
145+
}
146+
147+
switch $0 {
148+
case .docker:
149+
// specifically allow Docker by basename of the socket
150+
contents += "(remote unix-socket (regex \"*/docker.sock\"))"
151+
case .unixDomainSocket:
152+
// this allows unix domain sockets
153+
contents += "(remote unix-socket)"
154+
default:
155+
break
156+
}
157+
}
158+
159+
contents += "\n)\n"
160+
}
161+
98162
// The following accesses are only needed when interpreting the manifest (versus running a compiled version).
99163
if strictness == .manifest_pre_53 {
100164
// This is required by the Swift compiler.

Sources/Commands/PackageTools/PluginCommand.swift

Lines changed: 106 additions & 22 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,17 @@ 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+
case docker
48+
case unixDomainSocket
49+
}
50+
51+
@Option(name: .customLong("allow-network-connections"))
52+
var allowNetworkConnections: NetworkPermission = .none
4153
}
4254

4355
@OptionGroup()
@@ -116,38 +128,73 @@ struct PluginCommand: SwiftCommand {
116128
// 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.
117129
let outputDir = pluginsDir.appending(component: "outputs")
118130

131+
var allowNetworkConnections = [SandboxNetworkPermission.init(options.allowNetworkConnections)]
119132
// Determine the set of directories under which plugins are allowed to write. We always include the output directory.
120133
var writableDirectories = [outputDir]
121134
if options.allowWritingToPackageDirectory {
122135
writableDirectories.append(package.path)
123136
}
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)
137+
138+
// If the plugin requires permissions, we ask the user for approval.
139+
if case .command(_, let permissions) = plugin.capability {
140+
try permissions.forEach {
141+
let permissionString: String
142+
let reasonString: String
143+
let remedyOption: String
144+
145+
switch $0 {
146+
case .writeToPackageDirectory(let reason):
147+
guard !options.allowWritingToPackageDirectory else { return } // permission already granted
148+
permissionString = "write to the package directory"
149+
reasonString = reason
150+
remedyOption = "--allow-writing-to-package-directory"
151+
case .allowNetworkConnections(let scope, let reason):
152+
guard scope != .none else { return } // no need to prompt
153+
guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted
154+
155+
switch scope {
156+
case .all, .local:
157+
let portsString = scope.ports.isEmpty ? "on all ports" : "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))"
158+
permissionString = "allow \(scope.label) network connections \(portsString)"
159+
case .docker, .unixDomainSocket:
160+
permissionString = "allow \(scope.label) connections"
161+
case .none:
162+
permissionString = "" // should not be reached
142163
}
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"))
164+
165+
reasonString = reason
166+
remedyOption = "--allow-network-connections \(PluginCommand.PluginOptions.NetworkPermission.init(scope).defaultValueDescription)"
167+
}
168+
169+
let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)."
170+
let reason = "Stated reason: “\(reasonString)”."
171+
if swiftTool.outputStream.isTTY {
172+
// We can ask the user directly, so we do so.
173+
let query = "Allow this plugin to \(permissionString)?"
174+
swiftTool.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8)
175+
swiftTool.outputStream.flush()
176+
let answer = readLine(strippingNewline: true)
177+
// Throw an error if we didn't get permission.
178+
if answer?.lowercased() != "yes" {
179+
throw StringError("Plugin was denied permission to \(permissionString).")
147180
}
181+
} else {
182+
// We can't ask the user, so emit an error suggesting passing the flag.
183+
let remedy = "Use `\(remedyOption)` to allow this."
184+
throw StringError([problem, reason, remedy].joined(separator: "\n"))
185+
}
186+
187+
switch $0 {
188+
case .writeToPackageDirectory:
189+
// Otherwise append the directory to the list of allowed ones.
190+
writableDirectories.append(package.path)
191+
case .allowNetworkConnections(let scope, _):
192+
allowNetworkConnections.append(.init(scope))
193+
break
148194
}
149195
}
150196
}
197+
151198
for pathString in options.additionalAllowedWritableDirectories {
152199
writableDirectories.append(try AbsolutePath(validating: pathString, relativeTo: swiftTool.originalWorkingDirectory))
153200
}
@@ -187,6 +234,7 @@ struct PluginCommand: SwiftCommand {
187234
accessibleTools: accessibleTools,
188235
writableDirectories: writableDirectories,
189236
readOnlyDirectories: readOnlyDirectories,
237+
allowNetworkConnections: allowNetworkConnections,
190238
pkgConfigDirectories: swiftTool.options.locations.pkgConfigDirectories,
191239
fileSystem: swiftTool.fileSystem,
192240
observabilityScope: swiftTool.observabilityScope,
@@ -223,3 +271,39 @@ extension PluginCommandIntent {
223271
}
224272
}
225273
}
274+
275+
extension SandboxNetworkPermission {
276+
init(_ scope: PluginNetworkPermissionScope) {
277+
switch scope {
278+
case .none: self = .none
279+
case .local(let ports): self = .local(ports: ports)
280+
case .all(let ports): self = .all(ports: ports)
281+
case .docker: self = .docker
282+
case .unixDomainSocket: self = .unixDomainSocket
283+
}
284+
}
285+
}
286+
287+
extension PluginCommand.PluginOptions.NetworkPermission {
288+
fileprivate init(_ scope: PluginNetworkPermissionScope) {
289+
switch scope {
290+
case .unixDomainSocket: self = .unixDomainSocket
291+
case .docker: self = .docker
292+
case .none: self = .none
293+
case .all: self = .all
294+
case .local: self = .local
295+
}
296+
}
297+
}
298+
299+
extension SandboxNetworkPermission {
300+
init(_ permission: PluginCommand.PluginOptions.NetworkPermission) {
301+
switch permission {
302+
case .none: self = .none
303+
case .local: self = .local(ports: [])
304+
case .all: self = .all(ports: [])
305+
case .docker: self = .docker
306+
case .unixDomainSocket: self = .unixDomainSocket
307+
}
308+
}
309+
}

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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,14 +186,38 @@ 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+
case docker
194+
case unixDomainSocket
195+
196+
init(_ scope: PluginNetworkPermissionScope) {
197+
switch scope {
198+
case .none: self = .none
199+
case .local(let ports): self = .local(ports: ports)
200+
case .all(let ports): self = .all(ports: ports)
201+
case .docker: self = .docker
202+
case .unixDomainSocket: self = .unixDomainSocket
203+
}
204+
}
205+
}
206+
189207
let type: String
190208
let reason: String
209+
let networkScope: NetworkScope
191210

192211
init(from permission: PackageModel.PluginPermission) {
193212
switch permission {
194213
case .writeToPackageDirectory(let reason):
195214
self.type = "writeToPackageDirectory"
196215
self.reason = reason
216+
self.networkScope = .none
217+
case .allowNetworkConnections(let scope, let reason):
218+
self.type = "allowNetworkConnections"
219+
self.reason = reason
220+
self.networkScope = .init(scope)
197221
}
198222
}
199223
}

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: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,35 @@ 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+
case .docker: try container.encode("docker")
309+
case .unixDomainSocket: try container.encode("unix-socket")
310+
}
311+
}
312+
313+
var ports: [UInt8] {
314+
switch self {
315+
case .all(let ports): return ports
316+
case .local(let ports): return ports
317+
case .none, .docker, .unixDomainSocket: return []
318+
}
319+
}
320+
}
321+
301322
/// `Encodable` conformance.
302323
extension PluginPermission: Encodable {
303324
private enum CodingKeys: CodingKey {
304-
case type, reason
325+
case type, reason, scope, ports
305326
}
306327

307328
private enum PermissionType: String, Encodable {
329+
case allowNetworkConnections
308330
case writeToPackageDirectory
309331
}
310332

@@ -314,6 +336,11 @@ extension PluginPermission: Encodable {
314336
public func encode(to encoder: Encoder) throws {
315337
var container = encoder.container(keyedBy: CodingKeys.self)
316338
switch self {
339+
case ._allowNetworkConnections(let scope, let reason):
340+
try container.encode(PermissionType.allowNetworkConnections, forKey: .type)
341+
try container.encode(reason, forKey: .reason)
342+
try container.encode(scope, forKey: .scope)
343+
try container.encode(scope.ports, forKey: .ports)
317344
case ._writeToPackageDirectory(let reason):
318345
try container.encode(PermissionType.writeToPackageDirectory, forKey: .type)
319346
try container.encode(reason, forKey: .reason)

0 commit comments

Comments
 (0)