mxc 0.1.0-x86_64-linux → 0.3.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 +3 -2
- data/README.md +18 -6
- 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/native.rb +8 -1
- data/lib/mxc/preflight.rb +34 -3
- data/lib/mxc/result.rb +18 -3
- data/lib/mxc/sandbox.rb +132 -15
- data/lib/mxc/startup_failure.rb +13 -8
- data/lib/mxc/version.rb +1 -1
- data/lib/mxc.rb +20 -4
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e1fede2f737ced692fc0707a6aa148cb471456c51975a69b01453946be3cf86f
|
|
4
|
+
data.tar.gz: c71d74ea18026d8e8e7b4aa543c6fc54a87838986c244bf23576c4ecbe618140
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c224454b5f04ddd562bf747cba8690b8337ec9a7b38300a52e38948eb087101cae22c5638330f955c2b26e6f06a33549406fe9691c8f47efb6aa148181bd472f
|
|
7
|
+
data.tar.gz: 51a0d65079bd000c4f8bd1eb255b6e98f73ffde581d9913273a6bf34a4ea067ac14d26a6e178933aebe0e5255cf73f720ad47ddde26a2fb0241f37bdafc87ce5
|
data/NOTICE
CHANGED
|
@@ -5,8 +5,9 @@ This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
|
|
|
5
5
|
endorsed by Microsoft.
|
|
6
6
|
|
|
7
7
|
The native extension compiles and links the mxc-sdk crate
|
|
8
|
-
(https://github.com/microsoft/mxc, https://crates.io/crates/mxc-sdk),
|
|
9
|
-
|
|
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
|
+
and a Landlock backend, which is distributed under the MIT License:
|
|
10
11
|
|
|
11
12
|
MIT License
|
|
12
13
|
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, and how
|
|
17
|
+
Your app runs code it did not write: scripts a model generated, skills users uploaded, build steps from someone else. MXC for Ruby runs them in a sandbox that decides what they can read, write, and reach, how long they get, and how much CPU and memory they can use.
|
|
18
18
|
|
|
19
19
|
```ruby
|
|
20
20
|
result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
|
|
@@ -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.
|
|
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
|
|
@@ -68,6 +70,14 @@ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
|
|
|
68
70
|
result.blocked # => hosts the allow-list refused
|
|
69
71
|
```
|
|
70
72
|
|
|
73
|
+
Limit what a command can use, and lower its priority so your app stays responsive:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result = sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes).with_priority(:low).run("python3 build.py")
|
|
77
|
+
result.cpu_time # => 3.42
|
|
78
|
+
result.limit_exceeded # => nil, or :cpu_time when it ran out
|
|
79
|
+
```
|
|
80
|
+
|
|
71
81
|
Stream output as it arrives:
|
|
72
82
|
|
|
73
83
|
```ruby
|
|
@@ -92,6 +102,7 @@ class SkillSandbox < MXC::Sandbox
|
|
|
92
102
|
writable "tmp/skills"
|
|
93
103
|
network false
|
|
94
104
|
timeout 30
|
|
105
|
+
limits cpu_time: 60, memory: 1.gigabyte
|
|
95
106
|
end
|
|
96
107
|
|
|
97
108
|
SkillSandbox.new.run("python3 app/skills/build.py")
|
|
@@ -108,15 +119,16 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
|
|
|
108
119
|
|
|
109
120
|
## Platforms
|
|
110
121
|
|
|
111
|
-
| Platform | Backend | 0.
|
|
122
|
+
| Platform | Backend | 0.3.0 |
|
|
112
123
|
| --- | --- | --- |
|
|
113
|
-
| Linux | Bubblewrap | Supported and tested |
|
|
114
|
-
|
|
|
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 |
|
|
126
|
+
| macOS | Seatbelt | Supported and tested, without memory or process limits |
|
|
115
127
|
| Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
|
|
116
128
|
|
|
117
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.
|
|
118
130
|
|
|
119
|
-
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.
|
|
120
132
|
|
|
121
133
|
## Documentation
|
|
122
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
|
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
|
@@ -12,15 +12,46 @@ module MXC
|
|
|
12
12
|
'a sandbox to a proxy only by also accepting inbound connections to it'
|
|
13
13
|
WINDOWS_DENIED = 'Leave the paths out of readable and writable instead, or run on a Windows build where ' \
|
|
14
14
|
'MXC.supports?(:filesystem_denied_paths) is true'
|
|
15
|
+
MACOS_MEMORY = 'Leave memory out of the limits on macOS, which rejects memory rlimits. ' \
|
|
16
|
+
'Linux and Windows support it'
|
|
17
|
+
PROCESS_LIMIT = 'Leave processes out of the limits. Only Windows can count the processes of one sandbox; ' \
|
|
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"
|
|
15
22
|
|
|
16
23
|
module_function
|
|
17
24
|
|
|
18
|
-
def check!(network:, denied: [])
|
|
19
|
-
error = problem(network:, denied:)
|
|
25
|
+
def check!(network:, denied: [], resources: nil, backend: :auto)
|
|
26
|
+
error = problem(network:, denied:, resources:, backend:)
|
|
20
27
|
raise error if error
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: 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)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def resource_problem(resources)
|
|
43
|
+
return if resources.nil? || windows?
|
|
44
|
+
|
|
45
|
+
if resources[:max_processes]
|
|
46
|
+
UnsupportedError.new("Process limits are not supported on #{linux? ? 'Linux' : 'macOS'}",
|
|
47
|
+
code: :backend_unavailable, remediation: PROCESS_LIMIT)
|
|
48
|
+
elsif resources[:memory_mb] && !linux?
|
|
49
|
+
UnsupportedError.new('Memory limits are not supported on macOS',
|
|
50
|
+
code: :backend_unavailable, remediation: MACOS_MEMORY)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def host_problem(network, denied, path, tun)
|
|
24
55
|
return windows_problem(network, denied) if windows?
|
|
25
56
|
return unless network && linux?
|
|
26
57
|
|
data/lib/mxc/result.rb
CHANGED
|
@@ -25,7 +25,18 @@ module MXC
|
|
|
25
25
|
# Returns the backend that ran the command, such as <tt>:bubblewrap</tt>.
|
|
26
26
|
attr_reader :backend
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# Returns the CPU time the command used, user and system, in seconds as a Float. It
|
|
29
|
+
# includes every process the command started and waited for. Returns nil unless the
|
|
30
|
+
# sandbox has limits or a priority.
|
|
31
|
+
attr_reader :cpu_time
|
|
32
|
+
|
|
33
|
+
# Returns the limit the command ran into: <tt>:cpu_time</tt>, <tt>:memory</tt>,
|
|
34
|
+
# <tt>:processes</tt>, or nil. Linux and macOS report <tt>:cpu_time</tt> only, because a
|
|
35
|
+
# program that cannot allocate memory decides itself how to fail.
|
|
36
|
+
attr_reader :limit_exceeded
|
|
37
|
+
|
|
38
|
+
def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:, # :nodoc:
|
|
39
|
+
cpu_time: nil, limit_exceeded: nil)
|
|
29
40
|
@stdout = text(stdout)
|
|
30
41
|
@stderr = text(stderr)
|
|
31
42
|
@exit_code = exit_code
|
|
@@ -33,6 +44,8 @@ module MXC
|
|
|
33
44
|
@warnings = warnings.freeze
|
|
34
45
|
@blocked = blocked.freeze
|
|
35
46
|
@backend = backend
|
|
47
|
+
@cpu_time = cpu_time
|
|
48
|
+
@limit_exceeded = limit_exceeded
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
# Returns true when the command exited with status 0 before its timeout.
|
|
@@ -47,11 +60,13 @@ module MXC
|
|
|
47
60
|
|
|
48
61
|
# Returns the result as a Hash.
|
|
49
62
|
def to_h
|
|
50
|
-
{ stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend
|
|
63
|
+
{ stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend:, cpu_time:,
|
|
64
|
+
limit_exceeded: }
|
|
51
65
|
end
|
|
52
66
|
|
|
53
67
|
def inspect # :nodoc:
|
|
54
|
-
"
|
|
68
|
+
usage = " cpu_time=#{cpu_time.round(3)} limit_exceeded=#{limit_exceeded.inspect}" if cpu_time
|
|
69
|
+
"#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?}#{usage} backend=#{backend.inspect}>"
|
|
55
70
|
end
|
|
56
71
|
|
|
57
72
|
private
|
data/lib/mxc/sandbox.rb
CHANGED
|
@@ -22,19 +22,26 @@ module MXC
|
|
|
22
22
|
# writable "tmp/skills"
|
|
23
23
|
# network false
|
|
24
24
|
# timeout 30
|
|
25
|
+
# limits cpu_time: 60, memory: 2 * 1024**3
|
|
26
|
+
# priority :low
|
|
25
27
|
# end
|
|
26
28
|
#
|
|
27
29
|
# SkillSandbox.new.run("python3 app/skills/build.py")
|
|
28
30
|
class Sandbox
|
|
29
|
-
BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
|
|
30
|
-
|
|
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 network env directory timeout backend limits priority].freeze # :nodoc:
|
|
31
34
|
HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
|
|
32
35
|
WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
|
|
33
36
|
PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
|
|
34
37
|
BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
|
|
35
38
|
MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
|
|
39
|
+
LIMITS = %i[cpu_time memory processes].freeze # :nodoc:
|
|
40
|
+
PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
|
|
41
|
+
MEGABYTE = 1024 * 1024 # :nodoc:
|
|
36
42
|
DEFAULTS = {
|
|
37
|
-
readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
|
|
43
|
+
readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
|
|
44
|
+
limits: {}, priority: nil
|
|
38
45
|
}.freeze # :nodoc:
|
|
39
46
|
|
|
40
47
|
class << self
|
|
@@ -62,6 +69,12 @@ module MXC
|
|
|
62
69
|
# Sets every instance's backend.
|
|
63
70
|
def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
|
|
64
71
|
|
|
72
|
+
# Sets every instance's resource limits. Takes the same arguments as #with_limits.
|
|
73
|
+
def limits(...) = configure { |sandbox| sandbox.with_limits(...) }
|
|
74
|
+
|
|
75
|
+
# Sets every instance's scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
|
|
76
|
+
def priority(level) = configure { |sandbox| sandbox.with_priority(level) }
|
|
77
|
+
|
|
65
78
|
def defaults # :nodoc:
|
|
66
79
|
@defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
|
|
67
80
|
end
|
|
@@ -95,9 +108,11 @@ module MXC
|
|
|
95
108
|
|
|
96
109
|
# Returns a new sandbox with the class's defaults, then applies +options+ through
|
|
97
110
|
# the matching setters: <tt>readable:</tt>, <tt>writable:</tt>, <tt>denied:</tt>,
|
|
98
|
-
# <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt
|
|
111
|
+
# <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt>, <tt>backend:</tt>,
|
|
112
|
+
# <tt>limits:</tt> and <tt>priority:</tt>.
|
|
99
113
|
#
|
|
100
114
|
# MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
|
|
115
|
+
# MXC::Sandbox.new(limits: { cpu_time: 60, memory: 2 * 1024**3 }, priority: :low)
|
|
101
116
|
def initialize(**options)
|
|
102
117
|
unknown = options.keys - OPTIONS
|
|
103
118
|
raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
|
|
@@ -135,6 +150,13 @@ module MXC
|
|
|
135
150
|
# Returns the backend, falling back to MXC.config.backend.
|
|
136
151
|
def backend = @settings[:backend] || MXC.config.backend
|
|
137
152
|
|
|
153
|
+
# Returns the resource limits, such as <tt>{ cpu_time: 60, memory: 2147483648 }</tt>.
|
|
154
|
+
# A limit that is not set is absent.
|
|
155
|
+
def limits = @settings[:limits].dup.freeze
|
|
156
|
+
|
|
157
|
+
# Returns the scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
|
|
158
|
+
def priority = @settings[:priority] || :normal
|
|
159
|
+
|
|
138
160
|
# Adds paths the command can read. Accepts Strings and Pathnames; relative paths
|
|
139
161
|
# are expanded from the current directory when the command runs.
|
|
140
162
|
def with_readable(*paths)
|
|
@@ -197,13 +219,53 @@ module MXC
|
|
|
197
219
|
end
|
|
198
220
|
|
|
199
221
|
# Sets the backend: <tt>:auto</tt> (Bubblewrap on Linux, Seatbelt on macOS,
|
|
200
|
-
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:seatbelt</tt>,
|
|
201
|
-
# <tt>:process_container</tt>.
|
|
222
|
+
# ProcessContainer on Windows), <tt>:bubblewrap</tt>, <tt>:landlock</tt>, <tt>:seatbelt</tt>,
|
|
223
|
+
# or <tt>:process_container</tt>.
|
|
224
|
+
#
|
|
225
|
+
# On Linux, <tt>:auto</tt> runs on Landlock when Bubblewrap cannot create its namespaces,
|
|
226
|
+
# and says so in Result#warnings. <tt>:bubblewrap</tt> never does. <tt>:landlock</tt>
|
|
227
|
+
# needs Linux 6.2 or newer and runs without network access.
|
|
202
228
|
def with_backend(name)
|
|
203
229
|
@settings[:backend] = name && self.class.backend_name(name)
|
|
204
230
|
self
|
|
205
231
|
end
|
|
206
232
|
|
|
233
|
+
# Sets resource limits. Each process the command starts gets its own CPU time and memory.
|
|
234
|
+
#
|
|
235
|
+
# <tt>cpu_time:</tt> is the CPU time each process may use, in seconds. It accepts
|
|
236
|
+
# anything that responds to +to_f+, such as <tt>90</tt> or <tt>2.minutes</tt>, and is
|
|
237
|
+
# rounded up to whole seconds on Linux and macOS. A process that uses it up is stopped.
|
|
238
|
+
#
|
|
239
|
+
# <tt>memory:</tt> is the memory each process may allocate, in bytes. It accepts
|
|
240
|
+
# anything that responds to +to_i+, such as <tt>2 * 1024**3</tt> or
|
|
241
|
+
# <tt>2.gigabytes</tt>, and is rounded up to whole megabytes. Allocations beyond it
|
|
242
|
+
# fail. Not available on macOS.
|
|
243
|
+
#
|
|
244
|
+
# <tt>processes:</tt> is how many processes the command may run at the same time.
|
|
245
|
+
# Windows only.
|
|
246
|
+
#
|
|
247
|
+
# A limit given as +nil+ is removed, and <tt>with_limits(nil)</tt> removes them all.
|
|
248
|
+
# Raises ArgumentError for an unknown limit or a value that is not positive.
|
|
249
|
+
#
|
|
250
|
+
# sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes)
|
|
251
|
+
def with_limits(reset = (keep = true), **limits)
|
|
252
|
+
unless keep
|
|
253
|
+
raise ArgumentError, "pass limits as keywords, or nil to remove them, got #{reset.inspect}" unless reset.nil?
|
|
254
|
+
|
|
255
|
+
@settings[:limits].clear
|
|
256
|
+
end
|
|
257
|
+
limits.each { |name, value| set_limit(name, value) }
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Sets the scheduling priority of every process the command starts: <tt>:normal</tt>
|
|
262
|
+
# (the default), <tt>:low</tt> (nice 10 on Linux and macOS, below normal on
|
|
263
|
+
# Windows), or <tt>:lowest</tt> (nice 19, or idle on Windows).
|
|
264
|
+
def with_priority(level)
|
|
265
|
+
@settings[:priority] = level && priority_name(level)
|
|
266
|
+
self
|
|
267
|
+
end
|
|
268
|
+
|
|
207
269
|
# Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
|
|
208
270
|
# String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
|
|
209
271
|
# on Windows. Two or more Strings are a program and its arguments, and each argument
|
|
@@ -222,7 +284,7 @@ module MXC
|
|
|
222
284
|
return stream(*command, &) if block_given?
|
|
223
285
|
|
|
224
286
|
outcome = StartupFailure.check!(Native.run(command_line(command), request))
|
|
225
|
-
Result.new(**outcome, backend: resolved_backend)
|
|
287
|
+
Result.new(**outcome, backend: resolved_backend(outcome[:warnings]))
|
|
226
288
|
end
|
|
227
289
|
|
|
228
290
|
# Starts +command+ and returns its Process without waiting for it. Takes the command
|
|
@@ -230,7 +292,8 @@ module MXC
|
|
|
230
292
|
#
|
|
231
293
|
# sandbox.spawn("python3", "-i", "-q")
|
|
232
294
|
def spawn(*command)
|
|
233
|
-
|
|
295
|
+
native = Native.spawn(command_line(command), request)
|
|
296
|
+
Process.new(native, backend: resolved_backend(native.warnings))
|
|
234
297
|
end
|
|
235
298
|
|
|
236
299
|
# Returns true when this host can run this sandbox, including its network policy.
|
|
@@ -241,15 +304,18 @@ module MXC
|
|
|
241
304
|
# Returns the Availability of this sandbox on this host, with the reason and
|
|
242
305
|
# remediation when it cannot run.
|
|
243
306
|
def availability
|
|
244
|
-
problem = Preflight.problem(network:, denied:)
|
|
245
|
-
|
|
307
|
+
problem = Preflight.problem(network:, denied:, resources:, backend:)
|
|
308
|
+
host = MXC.availability(backend:) unless problem
|
|
309
|
+
problem ||= Preflight.problem(network:, backend: :landlock) if host&.runs_on == :landlock
|
|
310
|
+
return host unless problem
|
|
246
311
|
|
|
247
312
|
Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
|
|
248
313
|
end
|
|
249
314
|
|
|
250
315
|
def inspect # :nodoc:
|
|
316
|
+
resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
|
|
251
317
|
"#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
|
|
252
|
-
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
|
|
318
|
+
"readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
|
|
253
319
|
end
|
|
254
320
|
|
|
255
321
|
protected
|
|
@@ -266,10 +332,42 @@ module MXC
|
|
|
266
332
|
case name
|
|
267
333
|
when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
|
|
268
334
|
when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
|
|
335
|
+
when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
|
|
269
336
|
else public_send(:"with_#{name}", value)
|
|
270
337
|
end
|
|
271
338
|
end
|
|
272
339
|
|
|
340
|
+
def set_limit(name, value)
|
|
341
|
+
unless LIMITS.include?(name)
|
|
342
|
+
raise ArgumentError, "unknown limit #{name.inspect}, expected one of #{LIMITS.map(&:inspect).join(', ')}"
|
|
343
|
+
end
|
|
344
|
+
return @settings[:limits].delete(name) if value.nil?
|
|
345
|
+
|
|
346
|
+
@settings[:limits][name] = positive_limit(name, value)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def positive_limit(name, value)
|
|
350
|
+
amount = limit_amount(name, value)
|
|
351
|
+
raise ArgumentError, "#{name} must be a positive number, got #{value.inspect}" unless amount&.positive?
|
|
352
|
+
|
|
353
|
+
value
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def limit_amount(name, value)
|
|
357
|
+
return (value if value.is_a?(Integer)) if name == :processes
|
|
358
|
+
|
|
359
|
+
conversion = name == :cpu_time ? :to_f : :to_i
|
|
360
|
+
value.public_send(conversion) if value.respond_to?(conversion)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def priority_name(level)
|
|
364
|
+
level = level.to_sym
|
|
365
|
+
return level if PRIORITIES.key?(level)
|
|
366
|
+
|
|
367
|
+
raise ArgumentError,
|
|
368
|
+
"unknown priority #{level.inspect}, expected one of #{PRIORITIES.keys.map(&:inspect).join(', ')}"
|
|
369
|
+
end
|
|
370
|
+
|
|
273
371
|
def add_paths(name, paths)
|
|
274
372
|
list = @settings[name]
|
|
275
373
|
paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
|
|
@@ -314,15 +412,16 @@ module MXC
|
|
|
314
412
|
arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
|
|
315
413
|
end
|
|
316
414
|
|
|
317
|
-
def resolved_backend
|
|
415
|
+
def resolved_backend(warnings)
|
|
318
416
|
return backend unless backend == :auto
|
|
319
417
|
return :process_container if Preflight.windows?
|
|
418
|
+
return :seatbelt unless Preflight.linux?
|
|
320
419
|
|
|
321
|
-
|
|
420
|
+
warnings.any?(LANDLOCK_FALLBACK) ? :landlock : :bubblewrap
|
|
322
421
|
end
|
|
323
422
|
|
|
324
423
|
def request
|
|
325
|
-
Preflight.check!(network:, denied:)
|
|
424
|
+
Preflight.check!(network:, denied:, resources:, backend:)
|
|
326
425
|
{
|
|
327
426
|
readonly: expand(readable),
|
|
328
427
|
readwrite: expand(writable),
|
|
@@ -332,10 +431,28 @@ module MXC
|
|
|
332
431
|
timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
|
|
333
432
|
backend:,
|
|
334
433
|
network: network == true,
|
|
335
|
-
allow: network.is_a?(Hash) ? network[:allow] : nil
|
|
434
|
+
allow: network.is_a?(Hash) ? network[:allow] : nil,
|
|
435
|
+
resources:
|
|
336
436
|
}
|
|
337
437
|
end
|
|
338
438
|
|
|
439
|
+
protected
|
|
440
|
+
|
|
441
|
+
# The resource limits as MXC takes them, or nil when there are none.
|
|
442
|
+
def resources
|
|
443
|
+
limits = @settings[:limits]
|
|
444
|
+
return if limits.empty? && priority == :normal
|
|
445
|
+
|
|
446
|
+
{
|
|
447
|
+
cpu_time_ms: limits[:cpu_time] && (limits[:cpu_time].to_f * 1000).ceil,
|
|
448
|
+
memory_mb: limits[:memory] && -(-limits[:memory].to_i / MEGABYTE),
|
|
449
|
+
max_processes: limits[:processes],
|
|
450
|
+
priority: PRIORITIES.fetch(priority)
|
|
451
|
+
}.compact
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
private
|
|
455
|
+
|
|
339
456
|
def expand(paths)
|
|
340
457
|
paths.map { |path| native_path(File.expand_path(path)) }
|
|
341
458
|
end
|
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
|
@@ -19,6 +19,11 @@ require 'mxc/sandbox'
|
|
|
19
19
|
# result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
|
|
20
20
|
# result.stdout
|
|
21
21
|
module MXC
|
|
22
|
+
RESOURCE_CAPABILITIES = {
|
|
23
|
+
cpu_time_limit: { cpu_time_ms: 1 }, memory_limit: { memory_mb: 1 },
|
|
24
|
+
process_limit: { max_processes: 1 }, priority: { priority: 'low' }
|
|
25
|
+
}.freeze # :nodoc:
|
|
26
|
+
|
|
22
27
|
@availability = {}
|
|
23
28
|
@availability_lock = Mutex.new
|
|
24
29
|
|
|
@@ -82,22 +87,33 @@ module MXC
|
|
|
82
87
|
Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
# Returns true when this host supports +capability+ with the configured backend.
|
|
90
|
+
# Returns true when this host supports +capability+ with the configured backend. Where
|
|
91
|
+
# sandboxes run on Landlock, there is no <tt>:network</tt> or <tt>:network_policy</tt>.
|
|
86
92
|
#
|
|
87
93
|
# +capability+ is <tt>:network</tt> for unrestricted network access,
|
|
88
|
-
# <tt>:network_policy</tt> for host allow-lists,
|
|
94
|
+
# <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
|
|
95
|
+
# <tt>:memory_limit</tt>, <tt>:process_limit</tt> and <tt>:priority</tt> for the
|
|
96
|
+
# matching Sandbox#with_limits and Sandbox#with_priority settings, or any capability a
|
|
97
|
+
# Backend reports.
|
|
98
|
+
#
|
|
99
|
+
# MXC.supports?(:memory_limit) # => false on macOS
|
|
89
100
|
def supports?(capability)
|
|
90
101
|
return false unless supported?
|
|
91
102
|
|
|
92
103
|
case capability
|
|
93
|
-
when :network then Preflight.problem(network: true).nil?
|
|
94
|
-
when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
|
|
104
|
+
when :network then Preflight.problem(network: true, backend: runs_on).nil?
|
|
105
|
+
when :network_policy then Preflight.problem(network: { allow: [] }, backend: runs_on).nil? && proxy_enforcement?
|
|
106
|
+
when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
|
|
95
107
|
else configured_backends.any? { |backend| backend.supports?(capability) }
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
|
|
99
111
|
private
|
|
100
112
|
|
|
113
|
+
def runs_on
|
|
114
|
+
availability.runs_on
|
|
115
|
+
end
|
|
116
|
+
|
|
101
117
|
def proxy_enforcement?
|
|
102
118
|
!Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
|
|
103
119
|
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.3.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-17 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
|