mxc 0.2.0-x86_64-linux → 0.4.0-x86_64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/NOTICE +2 -2
- data/README.md +6 -3
- data/lib/mxc/3.2/mxc.so +0 -0
- data/lib/mxc/3.3/mxc.so +0 -0
- data/lib/mxc/3.4/mxc.so +0 -0
- data/lib/mxc/4.0/mxc.so +0 -0
- data/lib/mxc/availability.rb +9 -3
- data/lib/mxc/configuration.rb +2 -1
- data/lib/mxc/libraries.rb +138 -0
- data/lib/mxc/load_commands.rb +50 -0
- data/lib/mxc/native.rb +8 -1
- data/lib/mxc/preflight.rb +15 -4
- data/lib/mxc/sandbox.rb +88 -20
- data/lib/mxc/startup_failure.rb +13 -8
- data/lib/mxc/version.rb +1 -1
- data/lib/mxc.rb +10 -3
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a67f6e03b5528954d3960d5ecee562df2477e4eb35cd2201be7587172f55fde7
|
|
4
|
+
data.tar.gz: 4fe1e1c493013e84c4db05042647a2bdf52f1fffce6bc0dd0848b56ade99fa64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbbdf78fafbea341eb3ef4eaae54059456f89c376d12e836af77a89859726b397dfc67141305e5833c954958f73250442cfc700080c8346610a9d6487c2b3927
|
|
7
|
+
data.tar.gz: 41f99e3b573d98da712a2d63a86a4fb2754a81dac6106c9aa96dcef5d69b5ec2a11196ca7b9e32d41f27c29223688feae3ef774d04df4612bc776ea1868cdd25
|
data/NOTICE
CHANGED
|
@@ -6,8 +6,8 @@ endorsed by Microsoft.
|
|
|
6
6
|
|
|
7
7
|
The native extension compiles and links the mxc-sdk crate
|
|
8
8
|
(https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk), from
|
|
9
|
-
a fork at https://github.com/crmne/microsoft-mxc that adds resource limits
|
|
10
|
-
which is distributed under the MIT License:
|
|
9
|
+
a fork at https://github.com/crmne/microsoft-mxc that adds resource limits
|
|
10
|
+
and a Landlock backend, which is distributed under the MIT License:
|
|
11
11
|
|
|
12
12
|
MIT License
|
|
13
13
|
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ result.stdout
|
|
|
22
22
|
result.success?
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are
|
|
25
|
+
It is an unofficial Ruby binding to [Microsoft's MXC](https://github.com/microsoft/mxc), the MIT-licensed sandbox library. Not affiliated with or endorsed by Microsoft. Resource limits and the Landlock backend come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are part of MXC itself ([resource limits](https://github.com/microsoft/mxc/issues/1498), [Landlock](https://github.com/microsoft/mxc/issues/1494)).
|
|
26
26
|
|
|
27
27
|
## Installation
|
|
28
28
|
|
|
@@ -36,6 +36,8 @@ Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel an
|
|
|
36
36
|
sudo apt-get install bubblewrap
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+
Where Bubblewrap cannot create its namespaces, as in a default Docker container or on Ubuntu with AppArmor's restriction, sandboxes run on Landlock instead (Linux 6.2 or newer), without network access.
|
|
40
|
+
|
|
39
41
|
Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
|
|
40
42
|
|
|
41
43
|
## Usage
|
|
@@ -117,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
|
|
|
117
119
|
|
|
118
120
|
## Platforms
|
|
119
121
|
|
|
120
|
-
| Platform | Backend | 0.
|
|
122
|
+
| Platform | Backend | 0.3.0 |
|
|
121
123
|
| --- | --- | --- |
|
|
122
124
|
| Linux | Bubblewrap | Supported and tested, without process limits |
|
|
125
|
+
| Linux 6.2 and later | Landlock, where Bubblewrap cannot run or when asked for | Supported and tested, without network access or process limits |
|
|
123
126
|
| macOS | Seatbelt | Supported and tested, without memory or process limits |
|
|
124
127
|
| Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
|
|
125
128
|
|
|
126
129
|
On Windows, a single String runs through `cmd.exe` instead of `sh`. See [Running Commands](https://crmne.github.io/mxc/running-commands/#windows) and [Policies](https://crmne.github.io/mxc/policies/#windows) for what else differs.
|
|
127
130
|
|
|
128
|
-
Docker blocks the namespaces Bubblewrap needs by default. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows the smallest set of changes that makes
|
|
131
|
+
Docker blocks the namespaces Bubblewrap needs by default, so a default container runs sandboxes on Landlock. [Running in Docker](https://crmne.github.io/mxc/running-in-docker/) shows what that gives up, and the smallest set of changes that makes Bubblewrap work, with a seccomp profile and a Kamal configuration for a job role.
|
|
129
132
|
|
|
130
133
|
## Documentation
|
|
131
134
|
|
data/lib/mxc/3.2/mxc.so
CHANGED
|
Binary file
|
data/lib/mxc/3.3/mxc.so
CHANGED
|
Binary file
|
data/lib/mxc/3.4/mxc.so
CHANGED
|
Binary file
|
data/lib/mxc/4.0/mxc.so
CHANGED
|
Binary file
|
data/lib/mxc/availability.rb
CHANGED
|
@@ -16,9 +16,13 @@ module MXC
|
|
|
16
16
|
# Returns a suggestion for making the host supported, or nil.
|
|
17
17
|
attr_reader :remediation
|
|
18
18
|
|
|
19
|
+
# Returns the backend sandboxes run on, such as <tt>:bubblewrap</tt>, or <tt>:landlock</tt>
|
|
20
|
+
# when <tt>:auto</tt> falls back to it. Returns nil when no sandbox can start.
|
|
21
|
+
attr_reader :runs_on
|
|
22
|
+
|
|
19
23
|
def self.probe(backend) # :nodoc:
|
|
20
24
|
result = Sandbox.new(backend:, timeout: 10).run('exit 0')
|
|
21
|
-
return new(backend:, supported: true) if result.success?
|
|
25
|
+
return new(backend:, supported: true, runs_on: result.backend) if result.success?
|
|
22
26
|
|
|
23
27
|
outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
|
|
24
28
|
new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
|
|
@@ -26,8 +30,9 @@ module MXC
|
|
|
26
30
|
new(backend:, supported: false, reason: e.message, remediation: e.remediation)
|
|
27
31
|
end
|
|
28
32
|
|
|
29
|
-
def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
|
|
33
|
+
def initialize(backend:, supported:, reason: nil, remediation: nil, runs_on: nil) # :nodoc:
|
|
30
34
|
@backend = backend
|
|
35
|
+
@runs_on = runs_on
|
|
31
36
|
@supported = supported
|
|
32
37
|
@reason = reason
|
|
33
38
|
@remediation = remediation
|
|
@@ -39,7 +44,8 @@ module MXC
|
|
|
39
44
|
end
|
|
40
45
|
|
|
41
46
|
def inspect # :nodoc:
|
|
42
|
-
"#<MXC::Availability backend=#{backend.inspect} supported=#{supported?}
|
|
47
|
+
"#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} runs_on=#{runs_on.inspect} " \
|
|
48
|
+
"reason=#{reason.inspect}>"
|
|
43
49
|
end
|
|
44
50
|
end
|
|
45
51
|
end
|
data/lib/mxc/configuration.rb
CHANGED
|
@@ -15,7 +15,8 @@ module MXC
|
|
|
15
15
|
@timeout = nil
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>,
|
|
18
|
+
# Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, <tt>:landlock</tt>,
|
|
19
|
+
# <tt>:seatbelt</tt>, or <tt>:process_container</tt>.
|
|
19
20
|
def backend=(name)
|
|
20
21
|
@backend = Sandbox.backend_name(name)
|
|
21
22
|
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module MXC
|
|
7
|
+
# Finds the shared library files programs load, directly or through
|
|
8
|
+
# other libraries, so a sandbox can read them and nothing beside them. Only macOS needs this: Seatbelt
|
|
9
|
+
# sandboxes read the system's libraries but not those a package manager
|
|
10
|
+
# installs, such as Homebrew's, which a program like Ruby may link. The
|
|
11
|
+
# libraries come from the programs' load commands, read with otool from
|
|
12
|
+
# the Command Line Tools. Without them nothing is found, because
|
|
13
|
+
# /usr/bin/otool would offer to install them instead.
|
|
14
|
+
module Libraries # :nodoc:
|
|
15
|
+
OTOOL = '/usr/bin/otool'
|
|
16
|
+
XCODE_SELECT = '/usr/bin/xcode-select'
|
|
17
|
+
SYSTEM = %w[/usr/lib/ /System/ /Library/].freeze
|
|
18
|
+
TOKEN = %r{\A@(loader_path|executable_path|rpath)/}
|
|
19
|
+
|
|
20
|
+
@links = {}
|
|
21
|
+
@lock = Mutex.new
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Returns the real paths of the library files +binaries+ load outside
|
|
25
|
+
# the system's own, empty where none are needed or otool is missing.
|
|
26
|
+
# +executable+ is the program that loads them, for libraries named
|
|
27
|
+
# relative to it.
|
|
28
|
+
def files(binaries, executable: nil)
|
|
29
|
+
return [] if binaries.empty? || !available?
|
|
30
|
+
|
|
31
|
+
libraries(binaries, executable)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns the path of the program +arguments+ start: the first argument, or the first
|
|
35
|
+
# word of a command for the shell, found on +path+ or under +directory+. Returns nil
|
|
36
|
+
# when there is none.
|
|
37
|
+
def program(arguments, path:, directory:)
|
|
38
|
+
name = program_name(arguments)
|
|
39
|
+
return if name.nil?
|
|
40
|
+
return File.expand_path(name, directory || '/') if name.include?('/')
|
|
41
|
+
|
|
42
|
+
path.to_s.split(File::PATH_SEPARATOR).map { |entry| File.join(entry, name) }
|
|
43
|
+
.find { |candidate| File.file?(candidate) && File.executable?(candidate) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns true when this host can list a program's libraries.
|
|
47
|
+
def available?
|
|
48
|
+
return @available if defined?(@available)
|
|
49
|
+
|
|
50
|
+
@available = RUBY_PLATFORM.include?('darwin') && File.executable?(OTOOL) && developer_tools?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def program_name(arguments)
|
|
56
|
+
arguments.one? ? Shellwords.split(arguments.first).first : arguments.first
|
|
57
|
+
rescue ArgumentError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def libraries(binaries, executable)
|
|
62
|
+
roots = binaries.filter_map { |binary| real_path(binary) }
|
|
63
|
+
links_of(roots)
|
|
64
|
+
found = {}
|
|
65
|
+
roots.each { |root| visit(root, executable || root, [], found) }
|
|
66
|
+
found.keys
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def visit(path, executable, inherited, found)
|
|
70
|
+
dependencies(path, links_of([path]).fetch(path), executable, inherited).each do |library, rpaths|
|
|
71
|
+
next if found.key?(library)
|
|
72
|
+
|
|
73
|
+
found[library] = true
|
|
74
|
+
visit(library, executable, rpaths, found)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def developer_tools?
|
|
79
|
+
system(XCODE_SELECT, '-p', out: File::NULL, err: File::NULL) == true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dependencies(path, links, executable, inherited)
|
|
83
|
+
rpaths = links[:rpaths].filter_map { |rpath| expand(rpath, path, executable) } + inherited
|
|
84
|
+
links[:libraries].filter_map do |name|
|
|
85
|
+
next if system?(name)
|
|
86
|
+
|
|
87
|
+
library = locate(name, path, executable, rpaths)
|
|
88
|
+
[library, rpaths] if library && !system?(library)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def locate(name, loader, executable, rpaths)
|
|
93
|
+
relative = name.delete_prefix('@rpath/')
|
|
94
|
+
candidates = if relative == name
|
|
95
|
+
[expand(name, loader, executable)]
|
|
96
|
+
else
|
|
97
|
+
rpaths.map { |rpath| File.join(rpath, relative) }
|
|
98
|
+
end
|
|
99
|
+
candidates.compact.lazy.filter_map { |candidate| real_path(candidate) }.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def expand(name, loader, executable)
|
|
103
|
+
token = name[TOKEN, 1]
|
|
104
|
+
return name unless token
|
|
105
|
+
return if token == 'rpath'
|
|
106
|
+
|
|
107
|
+
base = token == 'loader_path' ? loader : executable
|
|
108
|
+
File.expand_path(name.sub(TOKEN, ''), File.dirname(base))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def system?(path)
|
|
112
|
+
path.start_with?(*SYSTEM)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def real_path(path)
|
|
116
|
+
path = File.realpath(path)
|
|
117
|
+
path if File.file?(path)
|
|
118
|
+
rescue SystemCallError
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def links_of(paths)
|
|
123
|
+
@lock.synchronize do
|
|
124
|
+
missing = paths.reject { |path| @links.key?(path) }
|
|
125
|
+
@links.merge!(LoadCommands.parse(otool(missing), missing)) if missing.any?
|
|
126
|
+
@links.slice(*paths)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def otool(paths)
|
|
131
|
+
output, = Open3.capture2(OTOOL, '-l', *paths, err: File::NULL)
|
|
132
|
+
output
|
|
133
|
+
rescue SystemCallError
|
|
134
|
+
''
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MXC
|
|
4
|
+
# Reads the libraries and run paths of Mach-O files from the load
|
|
5
|
+
# commands <tt>otool -l</tt> lists, with every architecture of a
|
|
6
|
+
# universal file together.
|
|
7
|
+
module LoadCommands # :nodoc:
|
|
8
|
+
LOAD = /\A\s*cmd LC_(?:LOAD_DYLIB|LOAD_WEAK_DYLIB|REEXPORT_DYLIB|LAZY_LOAD_DYLIB|LOAD_UPWARD_DYLIB)\z/
|
|
9
|
+
RPATH = /\A\s*cmd LC_RPATH\z/
|
|
10
|
+
COMMAND = /\A\s*cmd /
|
|
11
|
+
VALUE = /\A\s*(?:name|path) (.+) \(offset \d+\)\z/
|
|
12
|
+
ARCHITECTURE = / \(architecture [^)]+\)\z/
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Returns, for each of +paths+, the install names of the libraries it
|
|
16
|
+
# loads and its run paths, as <tt>{ libraries: [...], rpaths: [...] }</tt>.
|
|
17
|
+
def parse(output, paths)
|
|
18
|
+
links = paths.to_h { |path| [path, { libraries: [], rpaths: [] }] }
|
|
19
|
+
current = kind = nil
|
|
20
|
+
output.each_line(chomp: true) do |line|
|
|
21
|
+
if links.key?(header = header_path(line))
|
|
22
|
+
current = links[header]
|
|
23
|
+
kind = nil
|
|
24
|
+
elsif current
|
|
25
|
+
kind = record(current, kind, line)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
links
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def record(links, kind, line)
|
|
34
|
+
return :libraries if line.match?(LOAD)
|
|
35
|
+
return :rpaths if line.match?(RPATH)
|
|
36
|
+
return if line.match?(COMMAND)
|
|
37
|
+
|
|
38
|
+
value = line[VALUE, 1] if kind
|
|
39
|
+
return kind unless value
|
|
40
|
+
|
|
41
|
+
links[kind] << value
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def header_path(line)
|
|
46
|
+
line.delete_suffix(':').sub(ARCHITECTURE, '') if line.end_with?(':') && !line.start_with?(' ', "\t")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/mxc/native.rb
CHANGED
|
@@ -13,6 +13,9 @@ module MXC
|
|
|
13
13
|
MISSING_BUBBLEWRAP = /bwrap\) is not installed/
|
|
14
14
|
INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
|
|
15
15
|
'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
|
|
16
|
+
LANDLOCK_REFUSAL = /\ALandlock: /
|
|
17
|
+
LANDLOCK_POLICY = 'Change the policy as the message says, or run on Bubblewrap: a sandbox with backend :auto ' \
|
|
18
|
+
'runs on Landlock only where Bubblewrap cannot create its namespaces'
|
|
16
19
|
|
|
17
20
|
def self.backend_attributes
|
|
18
21
|
backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
|
|
@@ -21,8 +24,12 @@ module MXC
|
|
|
21
24
|
def self.error(message, code: nil, remediation: nil, **)
|
|
22
25
|
if message.match?(MISSING_BUBBLEWRAP)
|
|
23
26
|
UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
|
|
27
|
+
elsif message.match?(LANDLOCK_REFUSAL)
|
|
28
|
+
UnsupportedError.new(message, code:, remediation: remediation || LANDLOCK_POLICY, **)
|
|
29
|
+
elsif UNSUPPORTED_CODES.include?(code)
|
|
30
|
+
UnsupportedError.new(message, code:, remediation: remediation || StartupFailure.hint(message), **)
|
|
24
31
|
else
|
|
25
|
-
|
|
32
|
+
Error.new(message, code:, remediation:, **)
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
end
|
data/lib/mxc/preflight.rb
CHANGED
|
@@ -16,16 +16,27 @@ module MXC
|
|
|
16
16
|
'Linux and Windows support it'
|
|
17
17
|
PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
|
|
18
18
|
'in a container, limit them with docker run --pids-limit'
|
|
19
|
+
LANDLOCK_NETWORK = 'Use network: false, or the :bubblewrap backend on a host that allows unprivileged user ' \
|
|
20
|
+
'namespaces. Landlock cannot tell one address from another, so it could only allow the ' \
|
|
21
|
+
"whole host network, the host's loopback and inbound connections included"
|
|
19
22
|
|
|
20
23
|
module_function
|
|
21
24
|
|
|
22
|
-
def check!(network:, denied: [], resources: nil)
|
|
23
|
-
error = problem(network:, denied:, resources:)
|
|
25
|
+
def check!(network:, denied: [], resources: nil, backend: :auto)
|
|
26
|
+
error = problem(network:, denied:, resources:, backend:)
|
|
24
27
|
raise error if error
|
|
25
28
|
end
|
|
26
29
|
|
|
27
|
-
def problem(network:, denied: [], resources: nil, path: ENV.fetch('PATH', ''), tun: TUN)
|
|
28
|
-
resource_problem(resources) || host_problem(network, denied, path, tun)
|
|
30
|
+
def problem(network:, denied: [], resources: nil, backend: :auto, path: ENV.fetch('PATH', ''), tun: TUN)
|
|
31
|
+
resource_problem(resources) || landlock_problem(network, backend) || host_problem(network, denied, path, tun)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def landlock_problem(network, backend)
|
|
35
|
+
return unless network && backend == :landlock
|
|
36
|
+
|
|
37
|
+
kind = network.is_a?(Hash) ? 'Host allow-lists are' : 'Network access is'
|
|
38
|
+
UnsupportedError.new("#{kind} not supported on Landlock", code: :backend_unavailable,
|
|
39
|
+
remediation: LANDLOCK_NETWORK)
|
|
29
40
|
end
|
|
30
41
|
|
|
31
42
|
def resource_problem(resources)
|
data/lib/mxc/sandbox.rb
CHANGED
|
@@ -28,8 +28,10 @@ module MXC
|
|
|
28
28
|
#
|
|
29
29
|
# SkillSandbox.new.run("python3 app/skills/build.py")
|
|
30
30
|
class Sandbox
|
|
31
|
-
BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
|
|
32
|
-
|
|
31
|
+
BACKENDS = %i[auto bubblewrap landlock seatbelt process_container].freeze # :nodoc:
|
|
32
|
+
LANDLOCK_FALLBACK = /runs on Landlock instead/ # :nodoc:
|
|
33
|
+
OPTIONS = %i[readable writable denied libraries libraries_of network env directory timeout backend limits
|
|
34
|
+
priority].freeze # :nodoc:
|
|
33
35
|
HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
|
|
34
36
|
WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
|
|
35
37
|
PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
|
|
@@ -39,8 +41,8 @@ module MXC
|
|
|
39
41
|
PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
|
|
40
42
|
MEGABYTE = 1024 * 1024 # :nodoc:
|
|
41
43
|
DEFAULTS = {
|
|
42
|
-
readable: [], writable: [], denied: [],
|
|
43
|
-
limits: {}, priority: nil
|
|
44
|
+
readable: [], writable: [], denied: [], libraries: true, libraries_of: [], env: {}, network: false,
|
|
45
|
+
directory: nil, timeout: nil, backend: nil, limits: {}, priority: nil
|
|
44
46
|
}.freeze # :nodoc:
|
|
45
47
|
|
|
46
48
|
class << self
|
|
@@ -53,6 +55,13 @@ module MXC
|
|
|
53
55
|
# Adds paths every instance hides, even inside readable or writable paths.
|
|
54
56
|
def denied(*paths) = configure { |sandbox| sandbox.with_denied(*paths) }
|
|
55
57
|
|
|
58
|
+
# Adds programs and libraries whose shared libraries every instance can load.
|
|
59
|
+
# Takes the same arguments as #with_libraries_of.
|
|
60
|
+
def libraries_of(*paths) = configure { |sandbox| sandbox.with_libraries_of(*paths) }
|
|
61
|
+
|
|
62
|
+
# Sets whether every instance can read the libraries its programs load. See #with_libraries.
|
|
63
|
+
def libraries(enabled) = configure { |sandbox| sandbox.with_libraries(enabled) }
|
|
64
|
+
|
|
56
65
|
# Sets every instance's network access. Takes the same arguments as #with_network.
|
|
57
66
|
def network(...) = configure { |sandbox| sandbox.with_network(...) }
|
|
58
67
|
|
|
@@ -134,6 +143,12 @@ module MXC
|
|
|
134
143
|
# Returns the paths hidden from the command, as given.
|
|
135
144
|
def denied = @settings[:denied].dup.freeze
|
|
136
145
|
|
|
146
|
+
# Returns the programs and libraries whose shared libraries the command can load, as given.
|
|
147
|
+
def libraries_of = @settings[:libraries_of].dup.freeze
|
|
148
|
+
|
|
149
|
+
# Returns true when the command can read the libraries its program loads, the default.
|
|
150
|
+
def libraries = @settings[:libraries]
|
|
151
|
+
|
|
137
152
|
# Returns the environment variables set for the command.
|
|
138
153
|
def env = @settings[:env].dup.freeze
|
|
139
154
|
|
|
@@ -172,6 +187,32 @@ module MXC
|
|
|
172
187
|
add_paths(:denied, paths)
|
|
173
188
|
end
|
|
174
189
|
|
|
190
|
+
# Adds programs and libraries whose shared libraries the command loads, such as the
|
|
191
|
+
# extensions an interpreter opens. On macOS, the command can read those library files,
|
|
192
|
+
# and the libraries they load in turn, when they are not the system's own. The program
|
|
193
|
+
# the command runs needs no entry: #run grants its libraries already. Elsewhere, on a
|
|
194
|
+
# Mac without the Command Line Tools, and with <tt>with_libraries(false)</tt>, this
|
|
195
|
+
# grants nothing.
|
|
196
|
+
#
|
|
197
|
+
# sandbox.with_libraries_of(*Dir[File.join(RbConfig::CONFIG["archdir"], "**", "*.bundle")])
|
|
198
|
+
def with_libraries_of(*paths)
|
|
199
|
+
add_paths(:libraries_of, paths)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Sets whether the command can read the shared libraries its program and #libraries_of
|
|
203
|
+
# load, on macOS. +true+ is the default; +false+ grants no library, so the command reads
|
|
204
|
+
# only the system's and those under #readable. +nil+ resets it to +true+.
|
|
205
|
+
#
|
|
206
|
+
# sandbox.with_libraries(false)
|
|
207
|
+
def with_libraries(enabled)
|
|
208
|
+
unless [true, false, nil].include?(enabled)
|
|
209
|
+
raise ArgumentError, "libraries must be true, false or nil, got #{enabled.inspect}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
@settings[:libraries] = enabled != false
|
|
213
|
+
self
|
|
214
|
+
end
|
|
215
|
+
|
|
175
216
|
# Sets network access. The default, +false+, gives the command no network at all.
|
|
176
217
|
# +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
|
|
177
218
|
# may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
|
|
@@ -218,8 +259,12 @@ module MXC
|
|
|
218
259
|
end
|
|
219
260
|
|
|
220
261
|
# Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
|
|
221
|
-
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>,
|
|
222
|
-
# <tt>:process_container</tt>.
|
|
262
|
+
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:landlock</tt>, <tt>:seatbelt</tt>,
|
|
263
|
+
# or <tt>:process_container</tt>.
|
|
264
|
+
#
|
|
265
|
+
# On Linux, <tt>:auto</tt> runs on Landlock when Bubblewrap cannot create its namespaces,
|
|
266
|
+
# and says so in Result#warnings. <tt>:bubblewrap</tt> never does. <tt>:landlock</tt>
|
|
267
|
+
# needs Linux 6.2 or newer and runs without network access.
|
|
223
268
|
def with_backend(name)
|
|
224
269
|
@settings[:backend] = name && self.class.backend_name(name)
|
|
225
270
|
self
|
|
@@ -268,6 +313,12 @@ module MXC
|
|
|
268
313
|
#
|
|
269
314
|
# With a block, yields each Chunk of output as it arrives.
|
|
270
315
|
#
|
|
316
|
+
# On macOS, the command can read the shared library files its program links outside
|
|
317
|
+
# the system's own, such as Homebrew's, so programs built against them start. The
|
|
318
|
+
# program is the first argument, or the first word of a command for the shell, found
|
|
319
|
+
# on the +PATH+ of #env. See #with_libraries_of for libraries the program opens later,
|
|
320
|
+
# and #with_libraries to turn this off.
|
|
321
|
+
#
|
|
271
322
|
# sandbox.run("python3 build.py && ls dist")
|
|
272
323
|
# sandbox.run("python3", "convert.py", params[:filename])
|
|
273
324
|
# sandbox.run("python3", "build.py") { |chunk| print chunk.content }
|
|
@@ -278,8 +329,9 @@ module MXC
|
|
|
278
329
|
def run(*command, &)
|
|
279
330
|
return stream(*command, &) if block_given?
|
|
280
331
|
|
|
281
|
-
|
|
282
|
-
|
|
332
|
+
arguments = command_arguments(command)
|
|
333
|
+
outcome = StartupFailure.check!(Native.run(command_line(arguments), request(arguments)))
|
|
334
|
+
Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
|
|
283
335
|
end
|
|
284
336
|
|
|
285
337
|
# Starts +command+ and returns its Process without waiting for it. Takes the command
|
|
@@ -287,7 +339,9 @@ module MXC
|
|
|
287
339
|
#
|
|
288
340
|
# sandbox.spawn("python3", "-i", "-q")
|
|
289
341
|
def spawn(*command)
|
|
290
|
-
|
|
342
|
+
arguments = command_arguments(command)
|
|
343
|
+
native = Native.spawn(command_line(arguments), request(arguments))
|
|
344
|
+
Process.new(native, backend: resolved_backend(native.warnings))
|
|
291
345
|
end
|
|
292
346
|
|
|
293
347
|
# Returns true when this host can run this sandbox, including its network policy.
|
|
@@ -298,8 +352,10 @@ module MXC
|
|
|
298
352
|
# Returns the Availability of this sandbox on this host, with the reason and
|
|
299
353
|
# remediation when it cannot run.
|
|
300
354
|
def availability
|
|
301
|
-
problem = Preflight.problem(network:, denied:, resources:)
|
|
302
|
-
|
|
355
|
+
problem = Preflight.problem(network:, denied:, resources:, backend:)
|
|
356
|
+
host = MXC.availability(backend:) unless problem
|
|
357
|
+
problem ||= Preflight.problem(network:, backend: :landlock) if host&.runs_on == :landlock
|
|
358
|
+
return host unless problem
|
|
303
359
|
|
|
304
360
|
Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
|
|
305
361
|
end
|
|
@@ -307,7 +363,8 @@ module MXC
|
|
|
307
363
|
def inspect # :nodoc:
|
|
308
364
|
resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
|
|
309
365
|
"#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
|
|
310
|
-
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}
|
|
366
|
+
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}" \
|
|
367
|
+
"#{libraries_inspect}>"
|
|
311
368
|
end
|
|
312
369
|
|
|
313
370
|
protected
|
|
@@ -322,7 +379,7 @@ module MXC
|
|
|
322
379
|
|
|
323
380
|
def apply(name, value)
|
|
324
381
|
case name
|
|
325
|
-
when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
|
|
382
|
+
when :readable, :writable, :denied, :libraries_of then public_send(:"with_#{name}", *Array(value))
|
|
326
383
|
when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
|
|
327
384
|
when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
|
|
328
385
|
else public_send(:"with_#{name}", value)
|
|
@@ -376,8 +433,7 @@ module MXC
|
|
|
376
433
|
process&.kill unless finished
|
|
377
434
|
end
|
|
378
435
|
|
|
379
|
-
def command_line(
|
|
380
|
-
arguments = command_arguments(command)
|
|
436
|
+
def command_line(arguments)
|
|
381
437
|
return command_line_for_windows(arguments) if Preflight.windows?
|
|
382
438
|
|
|
383
439
|
arguments.one? ? arguments.first : Shellwords.join(arguments)
|
|
@@ -404,17 +460,18 @@ module MXC
|
|
|
404
460
|
arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
|
|
405
461
|
end
|
|
406
462
|
|
|
407
|
-
def resolved_backend
|
|
463
|
+
def resolved_backend(warnings)
|
|
408
464
|
return backend unless backend == :auto
|
|
409
465
|
return :process_container if Preflight.windows?
|
|
466
|
+
return :seatbelt unless Preflight.linux?
|
|
410
467
|
|
|
411
|
-
|
|
468
|
+
warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
|
|
412
469
|
end
|
|
413
470
|
|
|
414
|
-
def request
|
|
415
|
-
Preflight.check!(network:, denied:, resources:)
|
|
471
|
+
def request(arguments)
|
|
472
|
+
Preflight.check!(network:, denied:, resources:, backend:)
|
|
416
473
|
{
|
|
417
|
-
readonly: expand(readable),
|
|
474
|
+
readonly: expand(readable) | library_files(arguments),
|
|
418
475
|
readwrite: expand(writable),
|
|
419
476
|
denied: expand(denied) | hidden_resolver_sockets,
|
|
420
477
|
environment:,
|
|
@@ -465,6 +522,17 @@ module MXC
|
|
|
465
522
|
system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
|
|
466
523
|
end
|
|
467
524
|
|
|
525
|
+
def libraries_inspect
|
|
526
|
+
libraries ? (" libraries_of=#{libraries_of.inspect}" if libraries_of.any?) : ' libraries=false'
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def library_files(arguments)
|
|
530
|
+
return [] unless libraries && Libraries.available?
|
|
531
|
+
|
|
532
|
+
program = Libraries.program(arguments, path: env['PATH'], directory: working_directory)
|
|
533
|
+
Libraries.files([*program, *expand(libraries_of)], executable: program)
|
|
534
|
+
end
|
|
535
|
+
|
|
468
536
|
def hidden_resolver_sockets
|
|
469
537
|
return [] if network == true
|
|
470
538
|
|
data/lib/mxc/startup_failure.rb
CHANGED
|
@@ -7,7 +7,8 @@ module MXC
|
|
|
7
7
|
/setting up uid map/i, /unshare.*Operation not permitted/i)
|
|
8
8
|
PROC = /Can't mount proc|mount proc on/i
|
|
9
9
|
HOST_PROBLEMS = {
|
|
10
|
-
NAMESPACES => 'Unprivileged user namespaces are blocked.
|
|
10
|
+
NAMESPACES => 'Unprivileged user namespaces are blocked. Use backend :auto or :landlock to run on Landlock ' \
|
|
11
|
+
'instead, or allow them for Bubblewrap: on a host, enable kernel.unprivileged_userns_clone=1 ' \
|
|
11
12
|
'and, on Ubuntu 23.10 and later, allow bwrap in AppArmor ' \
|
|
12
13
|
'(kernel.apparmor_restrict_unprivileged_userns). ' \
|
|
13
14
|
"In Docker, run with a seccomp profile that allows namespaces: #{DOCKER_GUIDE}",
|
|
@@ -15,23 +16,27 @@ module MXC
|
|
|
15
16
|
"In Docker, see #{DOCKER_GUIDE}"
|
|
16
17
|
}.freeze
|
|
17
18
|
CHDIR = /Can't chdir/i
|
|
19
|
+
BWRAP_LINE = /^bwrap: .*$/
|
|
18
20
|
|
|
19
21
|
module_function
|
|
20
22
|
|
|
21
23
|
def detect(outcome)
|
|
22
|
-
|
|
24
|
+
failure = outcome[:failure] or return
|
|
25
|
+
reason = outcome[:stderr].to_s.scan(BWRAP_LINE).last
|
|
26
|
+
return Error.new(failure, code: :backend_error) unless reason
|
|
23
27
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
message = "Bubblewrap could not start the sandbox: #{stderr.strip}"
|
|
28
|
-
_, hint = HOST_PROBLEMS.find { |pattern, _| stderr.match?(pattern) }
|
|
28
|
+
message = "Bubblewrap could not start the sandbox: #{reason}"
|
|
29
|
+
hint = hint(reason)
|
|
29
30
|
return UnsupportedError.new(message, code: :backend_unavailable, remediation: hint, operation: 'bwrap') if hint
|
|
30
31
|
|
|
31
|
-
remediation = 'Make the directory readable or writable in the sandbox' if
|
|
32
|
+
remediation = 'Make the directory readable or writable in the sandbox' if reason.match?(CHDIR)
|
|
32
33
|
Error.new(message, code: :backend_error, remediation:, operation: 'bwrap')
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def hint(message)
|
|
37
|
+
HOST_PROBLEMS.find { |pattern, _| message.match?(pattern) }&.last
|
|
38
|
+
end
|
|
39
|
+
|
|
35
40
|
def check!(outcome)
|
|
36
41
|
error = detect(outcome)
|
|
37
42
|
raise error if error
|
data/lib/mxc/version.rb
CHANGED
data/lib/mxc.rb
CHANGED
|
@@ -12,6 +12,8 @@ require 'mxc/backend'
|
|
|
12
12
|
require 'mxc/availability'
|
|
13
13
|
require 'mxc/process'
|
|
14
14
|
require 'mxc/windows_arguments'
|
|
15
|
+
require 'mxc/load_commands'
|
|
16
|
+
require 'mxc/libraries'
|
|
15
17
|
require 'mxc/sandbox'
|
|
16
18
|
|
|
17
19
|
# Runs commands in sandboxes through Microsoft's MXC.
|
|
@@ -87,7 +89,8 @@ module MXC
|
|
|
87
89
|
Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
|
|
88
90
|
end
|
|
89
91
|
|
|
90
|
-
# Returns true when this host supports +capability+ with the configured backend.
|
|
92
|
+
# Returns true when this host supports +capability+ with the configured backend. Where
|
|
93
|
+
# sandboxes run on Landlock, there is no <tt>:network</tt> or <tt>:network_policy</tt>.
|
|
91
94
|
#
|
|
92
95
|
# +capability+ is <tt>:network</tt> for unrestricted network access,
|
|
93
96
|
# <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
|
|
@@ -100,8 +103,8 @@ module MXC
|
|
|
100
103
|
return false unless supported?
|
|
101
104
|
|
|
102
105
|
case capability
|
|
103
|
-
when :network then Preflight.problem(network: true).nil?
|
|
104
|
-
when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
|
|
106
|
+
when :network then Preflight.problem(network: true, backend: runs_on).nil?
|
|
107
|
+
when :network_policy then Preflight.problem(network: { allow: [] }, backend: runs_on).nil? && proxy_enforcement?
|
|
105
108
|
when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
|
|
106
109
|
else configured_backends.any? { |backend| backend.supports?(capability) }
|
|
107
110
|
end
|
|
@@ -109,6 +112,10 @@ module MXC
|
|
|
109
112
|
|
|
110
113
|
private
|
|
111
114
|
|
|
115
|
+
def runs_on
|
|
116
|
+
availability.runs_on
|
|
117
|
+
end
|
|
118
|
+
|
|
112
119
|
def proxy_enforcement?
|
|
113
120
|
!Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
|
|
114
121
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mxc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: x86_64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- Carmine Paolino
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: An unofficial Ruby binding to Microsoft's MXC. Run commands in Bubblewrap,
|
|
14
14
|
Seatbelt or Windows ProcessContainer sandboxes with filesystem, network, environment
|
|
@@ -32,6 +32,8 @@ files:
|
|
|
32
32
|
- lib/mxc/chunk.rb
|
|
33
33
|
- lib/mxc/configuration.rb
|
|
34
34
|
- lib/mxc/error.rb
|
|
35
|
+
- lib/mxc/libraries.rb
|
|
36
|
+
- lib/mxc/load_commands.rb
|
|
35
37
|
- lib/mxc/native.rb
|
|
36
38
|
- lib/mxc/preflight.rb
|
|
37
39
|
- lib/mxc/process.rb
|