mxc 0.1.0-x86_64-linux → 0.2.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: 86bb54e129b48e32e469fa771293b5d887064fad5a98c1ed7d47e98c823288e2
4
+ data.tar.gz: 22054e5c8fe708cf11fcacfba0e1612a296716f7f7f7f8428d2786ba93ca9e18
5
5
  SHA512:
6
- metadata.gz: 3b6af2d6bd101e36254b046de3f6236d2a8711faf1c93e38da019cc7ae7950b1450738d3977c2e03f1361878b968ea902f5bc0e6e304442104a6ff9260b97a95
7
- data.tar.gz: eedb50f24207a0b79b9f0c67cc15ca6b90e51bf96587f7b9d6efbbce81843909c4ae677c8be013a636eb617815ee3ab943031ebf2c044a86888825fe26191d73
6
+ metadata.gz: 4db21a001399af4632811e84238a37b32d9d21b10fead1d87e62599385a5aa2fa661690fd182a35d9a0c6453981e4ad39970ca0262dccb2144a3b435b1f73e34
7
+ data.tar.gz: eef8f390ec961e9940660c10f2020820a7e341c8e53e584385b80571f8876d8291f6c94796ccb5da110705d1d33af045ac73f6e70b649a3e2119af48539fa61f
data/NOTICE CHANGED
@@ -5,7 +5,8 @@ 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),
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,
9
10
  which is distributed under the MIT License:
10
11
 
11
12
  MIT License
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 come from [a fork of MXC](https://github.com/crmne/microsoft-mxc) until they are [part of MXC itself](https://github.com/microsoft/mxc/issues/1498).
26
26
 
27
27
  ## Installation
28
28
 
@@ -68,6 +68,14 @@ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
68
68
  result.blocked # => hosts the allow-list refused
69
69
  ```
70
70
 
71
+ Limit what a command can use, and lower its priority so your app stays responsive:
72
+
73
+ ```ruby
74
+ result = sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes).with_priority(:low).run("python3 build.py")
75
+ result.cpu_time # => 3.42
76
+ result.limit_exceeded # => nil, or :cpu_time when it ran out
77
+ ```
78
+
71
79
  Stream output as it arrives:
72
80
 
73
81
  ```ruby
@@ -92,6 +100,7 @@ class SkillSandbox < MXC::Sandbox
92
100
  writable "tmp/skills"
93
101
  network false
94
102
  timeout 30
103
+ limits cpu_time: 60, memory: 1.gigabyte
95
104
  end
96
105
 
97
106
  SkillSandbox.new.run("python3 app/skills/build.py")
@@ -108,10 +117,10 @@ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
108
117
 
109
118
  ## Platforms
110
119
 
111
- | Platform | Backend | 0.1.0 |
120
+ | Platform | Backend | 0.2.0 |
112
121
  | --- | --- | --- |
113
- | Linux | Bubblewrap | Supported and tested |
114
- | macOS | Seatbelt | Supported and tested |
122
+ | Linux | Bubblewrap | Supported and tested, without process limits |
123
+ | macOS | Seatbelt | Supported and tested, without memory or process limits |
115
124
  | Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
116
125
 
117
126
  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.
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/preflight.rb CHANGED
@@ -12,15 +12,35 @@ 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'
15
19
 
16
20
  module_function
17
21
 
18
- def check!(network:, denied: [])
19
- error = problem(network:, denied:)
22
+ def check!(network:, denied: [], resources: nil)
23
+ error = problem(network:, denied:, resources:)
20
24
  raise error if error
21
25
  end
22
26
 
23
- def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: TUN)
27
+ def problem(network:, denied: [], resources: nil, path: ENV.fetch('PATH', ''), tun: TUN)
28
+ resource_problem(resources) || host_problem(network, denied, path, tun)
29
+ end
30
+
31
+ def resource_problem(resources)
32
+ return if resources.nil? || windows?
33
+
34
+ if resources[:max_processes]
35
+ UnsupportedError.new("Process limits are not supported on #{linux? ? 'Linux' : 'macOS'}",
36
+ code: :backend_unavailable, remediation: PROCESS_LIMIT)
37
+ elsif resources[:memory_mb] && !linux?
38
+ UnsupportedError.new('Memory limits are not supported on macOS',
39
+ code: :backend_unavailable, remediation: MACOS_MEMORY)
40
+ end
41
+ end
42
+
43
+ def host_problem(network, denied, path, tun)
24
44
  return windows_problem(network, denied) if windows?
25
45
  return unless network && linux?
26
46
 
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,25 @@ 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
31
  BACKENDS = %i[auto bubblewrap seatbelt process_container].freeze # :nodoc:
30
- OPTIONS = %i[readable writable denied network env directory timeout backend].freeze # :nodoc:
32
+ OPTIONS = %i[readable writable denied network env directory timeout backend limits priority].freeze # :nodoc:
31
33
  HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
32
34
  WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
33
35
  PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
34
36
  BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
35
37
  MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
38
+ LIMITS = %i[cpu_time memory processes].freeze # :nodoc:
39
+ PRIORITIES = { normal: nil, low: 'low', lowest: 'idle' }.freeze # :nodoc:
40
+ MEGABYTE = 1024 * 1024 # :nodoc:
36
41
  DEFAULTS = {
37
- readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
42
+ readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil,
43
+ limits: {}, priority: nil
38
44
  }.freeze # :nodoc:
39
45
 
40
46
  class << self
@@ -62,6 +68,12 @@ module MXC
62
68
  # Sets every instance's backend.
63
69
  def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
64
70
 
71
+ # Sets every instance's resource limits. Takes the same arguments as #with_limits.
72
+ def limits(...) = configure { |sandbox| sandbox.with_limits(...) }
73
+
74
+ # Sets every instance's scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
75
+ def priority(level) = configure { |sandbox| sandbox.with_priority(level) }
76
+
65
77
  def defaults # :nodoc:
66
78
  @defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
67
79
  end
@@ -95,9 +107,11 @@ module MXC
95
107
 
96
108
  # Returns a new sandbox with the class's defaults, then applies +options+ through
97
109
  # 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>.
110
+ # <tt>network:</tt>, <tt>env:</tt>, <tt>directory:</tt>, <tt>timeout:</tt>, <tt>backend:</tt>,
111
+ # <tt>limits:</tt> and <tt>priority:</tt>.
99
112
  #
100
113
  # MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
114
+ # MXC::Sandbox.new(limits: { cpu_time: 60, memory: 2 * 1024**3 }, priority: :low)
101
115
  def initialize(**options)
102
116
  unknown = options.keys - OPTIONS
103
117
  raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
@@ -135,6 +149,13 @@ module MXC
135
149
  # Returns the backend, falling back to MXC.config.backend.
136
150
  def backend = @settings[:backend] || MXC.config.backend
137
151
 
152
+ # Returns the resource limits, such as <tt>{ cpu_time: 60, memory: 2147483648 }</tt>.
153
+ # A limit that is not set is absent.
154
+ def limits = @settings[:limits].dup.freeze
155
+
156
+ # Returns the scheduling priority: <tt>:normal</tt>, <tt>:low</tt>, or <tt>:lowest</tt>.
157
+ def priority = @settings[:priority] || :normal
158
+
138
159
  # Adds paths the command can read. Accepts Strings and Pathnames; relative paths
139
160
  # are expanded from the current directory when the command runs.
140
161
  def with_readable(*paths)
@@ -204,6 +225,42 @@ module MXC
204
225
  self
205
226
  end
206
227
 
228
+ # Sets resource limits. Each process the command starts gets its own CPU time and memory.
229
+ #
230
+ # <tt>cpu_time:</tt> is the CPU time each process may use, in seconds. It accepts
231
+ # anything that responds to +to_f+, such as <tt>90</tt> or <tt>2.minutes</tt>, and is
232
+ # rounded up to whole seconds on Linux and macOS. A process that uses it up is stopped.
233
+ #
234
+ # <tt>memory:</tt> is the memory each process may allocate, in bytes. It accepts
235
+ # anything that responds to +to_i+, such as <tt>2 * 1024**3</tt> or
236
+ # <tt>2.gigabytes</tt>, and is rounded up to whole megabytes. Allocations beyond it
237
+ # fail. Not available on macOS.
238
+ #
239
+ # <tt>processes:</tt> is how many processes the command may run at the same time.
240
+ # Windows only.
241
+ #
242
+ # A limit given as +nil+ is removed, and <tt>with_limits(nil)</tt> removes them all.
243
+ # Raises ArgumentError for an unknown limit or a value that is not positive.
244
+ #
245
+ # sandbox.with_limits(cpu_time: 2.minutes, memory: 2.gigabytes)
246
+ def with_limits(reset = (keep = true), **limits)
247
+ unless keep
248
+ raise ArgumentError, "pass limits as keywords, or nil to remove them, got #{reset.inspect}" unless reset.nil?
249
+
250
+ @settings[:limits].clear
251
+ end
252
+ limits.each { |name, value| set_limit(name, value) }
253
+ self
254
+ end
255
+
256
+ # Sets the scheduling priority of every process the command starts: <tt>:normal</tt>
257
+ # (the default), <tt>:low</tt> (nice 10 on Linux and macOS, below normal on
258
+ # Windows), or <tt>:lowest</tt> (nice 19, or idle on Windows).
259
+ def with_priority(level)
260
+ @settings[:priority] = level && priority_name(level)
261
+ self
262
+ end
263
+
207
264
  # Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
208
265
  # String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
209
266
  # on Windows. Two or more Strings are a program and its arguments, and each argument
@@ -241,15 +298,16 @@ module MXC
241
298
  # Returns the Availability of this sandbox on this host, with the reason and
242
299
  # remediation when it cannot run.
243
300
  def availability
244
- problem = Preflight.problem(network:, denied:)
301
+ problem = Preflight.problem(network:, denied:, resources:)
245
302
  return MXC.availability(backend:) unless problem
246
303
 
247
304
  Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
248
305
  end
249
306
 
250
307
  def inspect # :nodoc:
308
+ resources = " limits=#{limits.inspect} priority=#{priority.inspect}" if self.resources
251
309
  "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
252
- "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
310
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}#{resources}>"
253
311
  end
254
312
 
255
313
  protected
@@ -266,10 +324,42 @@ module MXC
266
324
  case name
267
325
  when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
268
326
  when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
327
+ when :limits then value.nil? ? with_limits(nil) : with_limits(**value)
269
328
  else public_send(:"with_#{name}", value)
270
329
  end
271
330
  end
272
331
 
332
+ def set_limit(name, value)
333
+ unless LIMITS.include?(name)
334
+ raise ArgumentError, "unknown limit #{name.inspect}, expected one of #{LIMITS.map(&:inspect).join(', ')}"
335
+ end
336
+ return @settings[:limits].delete(name) if value.nil?
337
+
338
+ @settings[:limits][name] = positive_limit(name, value)
339
+ end
340
+
341
+ def positive_limit(name, value)
342
+ amount = limit_amount(name, value)
343
+ raise ArgumentError, "#{name} must be a positive number, got #{value.inspect}" unless amount&.positive?
344
+
345
+ value
346
+ end
347
+
348
+ def limit_amount(name, value)
349
+ return (value if value.is_a?(Integer)) if name == :processes
350
+
351
+ conversion = name == :cpu_time ? :to_f : :to_i
352
+ value.public_send(conversion) if value.respond_to?(conversion)
353
+ end
354
+
355
+ def priority_name(level)
356
+ level = level.to_sym
357
+ return level if PRIORITIES.key?(level)
358
+
359
+ raise ArgumentError,
360
+ "unknown priority #{level.inspect}, expected one of #{PRIORITIES.keys.map(&:inspect).join(', ')}"
361
+ end
362
+
273
363
  def add_paths(name, paths)
274
364
  list = @settings[name]
275
365
  paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
@@ -322,7 +412,7 @@ module MXC
322
412
  end
323
413
 
324
414
  def request
325
- Preflight.check!(network:, denied:)
415
+ Preflight.check!(network:, denied:, resources:)
326
416
  {
327
417
  readonly: expand(readable),
328
418
  readwrite: expand(writable),
@@ -332,10 +422,28 @@ module MXC
332
422
  timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
333
423
  backend:,
334
424
  network: network == true,
335
- allow: network.is_a?(Hash) ? network[:allow] : nil
425
+ allow: network.is_a?(Hash) ? network[:allow] : nil,
426
+ resources:
336
427
  }
337
428
  end
338
429
 
430
+ protected
431
+
432
+ # The resource limits as MXC takes them, or nil when there are none.
433
+ def resources
434
+ limits = @settings[:limits]
435
+ return if limits.empty? && priority == :normal
436
+
437
+ {
438
+ cpu_time_ms: limits[:cpu_time] && (limits[:cpu_time].to_f * 1000).ceil,
439
+ memory_mb: limits[:memory] && -(-limits[:memory].to_i / MEGABYTE),
440
+ max_processes: limits[:processes],
441
+ priority: PRIORITIES.fetch(priority)
442
+ }.compact
443
+ end
444
+
445
+ private
446
+
339
447
  def expand(paths)
340
448
  paths.map { |path| native_path(File.expand_path(path)) }
341
449
  end
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.2.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
 
@@ -85,13 +90,19 @@ module MXC
85
90
  # Returns true when this host supports +capability+ with the configured backend.
86
91
  #
87
92
  # +capability+ is <tt>:network</tt> for unrestricted network access,
88
- # <tt>:network_policy</tt> for host allow-lists, or any capability a Backend reports.
93
+ # <tt>:network_policy</tt> for host allow-lists, <tt>:cpu_time_limit</tt>,
94
+ # <tt>:memory_limit</tt>, <tt>:process_limit</tt> and <tt>:priority</tt> for the
95
+ # matching Sandbox#with_limits and Sandbox#with_priority settings, or any capability a
96
+ # Backend reports.
97
+ #
98
+ # MXC.supports?(:memory_limit) # => false on macOS
89
99
  def supports?(capability)
90
100
  return false unless supported?
91
101
 
92
102
  case capability
93
103
  when :network then Preflight.problem(network: true).nil?
94
104
  when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
105
+ when *RESOURCE_CAPABILITIES.keys then Preflight.resource_problem(RESOURCE_CAPABILITIES[capability]).nil?
95
106
  else configured_backends.any? { |backend| backend.supports?(capability) }
96
107
  end
97
108
  end
metadata CHANGED
@@ -1,7 +1,7 @@
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.2.0
5
5
  platform: x86_64-linux
6
6
  authors:
7
7
  - Carmine Paolino