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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 937f45a4c10fe7ba6663b0385284ea1299601e105ff9629c0734dafce0db89b5
4
- data.tar.gz: 6f4f495281d51b6f85f4928e7115c6587d21600e688553e766cb93dcbf9b573a
3
+ metadata.gz: e1fede2f737ced692fc0707a6aa148cb471456c51975a69b01453946be3cf86f
4
+ data.tar.gz: c71d74ea18026d8e8e7b4aa543c6fc54a87838986c244bf23576c4ecbe618140
5
5
  SHA512:
6
- metadata.gz: 3b6af2d6bd101e36254b046de3f6236d2a8711faf1c93e38da019cc7ae7950b1450738d3977c2e03f1361878b968ea902f5bc0e6e304442104a6ff9260b97a95
7
- data.tar.gz: eedb50f24207a0b79b9f0c67cc15ca6b90e51bf96587f7b9d6efbbce81843909c4ae677c8be013a636eb617815ee3ab943031ebf2c044a86888825fe26191d73
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
- which is distributed under the MIT License:
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 long they get.
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.1.0 |
122
+ | Platform | Backend | 0.3.0 |
112
123
  | --- | --- | --- |
113
- | Linux | Bubblewrap | Supported and tested |
114
- | macOS | Seatbelt | Supported and tested |
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 it work, with a seccomp profile and a Kamal configuration for a job role.
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
@@ -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?} reason=#{reason.inspect}>"
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
@@ -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>, or <tt>:seatbelt</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
- (UNSUPPORTED_CODES.include?(code) ? UnsupportedError : Error).new(message, code:, remediation:, **)
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
- def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:) # :nodoc:
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
- "#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?} backend=#{backend.inspect}>"
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
- OPTIONS = %i[readable writable denied network env directory timeout backend].freeze # :nodoc:
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> and <tt>backend:</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>, or
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
- Process.new(Native.spawn(command_line(command), request), backend: resolved_backend)
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
- return MXC.availability(backend:) unless problem
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
- Preflight.linux? ? :bubblewrap : :seatbelt
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
@@ -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. On a host, enable kernel.unprivileged_userns_clone=1 ' \
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
- return unless outcome[:exit_code] == 1 && outcome[:stdout].to_s.empty?
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
- stderr = outcome[:stderr].to_s
25
- return unless stderr.match?(/\Abwrap: [^\n]*\n?\z/)
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 stderr.match?(CHDIR)
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module MXC
4
4
  # The gem version.
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end
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, or any capability a Backend reports.
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.1.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-14 00:00:00.000000000 Z
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