mxc 0.1.0-x86_64-darwin

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5df2901b524f2d2ad0711186469a7ac64a5af615ae5454efa94b225deb9dd15b
4
+ data.tar.gz: 3104eee3cbbd5673dfd771cb06d637a51a5f08b56b4d4e0729d84c48a1de7921
5
+ SHA512:
6
+ metadata.gz: 8227de796a7a0374fc75f74ec60c3bbc79b0479e95e68f7c3720795ee2a2c237fa27829117696a9d99afaa08f708cf631a592f691dd5e7c50867ed288d85c7a0
7
+ data.tar.gz: 98c95b7cbb95f3093ebb3abe664e20e21c3d5c5bdd2d8909b3ccf40f989a12940b11defcc01e08ca027e7d821d5343bbc47690d4379522da8a08c4aa68d86008
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carmine Paolino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NOTICE ADDED
@@ -0,0 +1,34 @@
1
+ MXC for Ruby
2
+ Copyright (c) 2026 Carmine Paolino
3
+
4
+ This gem is an unofficial Ruby binding to MXC. It is not affiliated with or
5
+ endorsed by Microsoft.
6
+
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:
10
+
11
+ MIT License
12
+
13
+ Copyright (c) Microsoft Corporation.
14
+
15
+ Permission is hereby granted, free of charge, to any person obtaining a copy
16
+ of this software and associated documentation files (the "Software"), to deal
17
+ in the Software without restriction, including without limitation the rights
18
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19
+ copies of the Software, and to permit persons to whom the Software is
20
+ furnished to do so, subject to the following conditions:
21
+
22
+ The above copyright notice and this permission notice shall be included in all
23
+ copies or substantial portions of the Software.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
27
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
28
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
29
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
30
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31
+ SOFTWARE.
32
+
33
+ The native extension also links other open source Rust crates, listed with
34
+ their versions in Cargo.lock. Their licenses are available from crates.io.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ <div align="center">
2
+
3
+ # MXC for Ruby
4
+
5
+ <strong>Run untrusted commands in an OS sandbox, from Ruby</strong>
6
+
7
+ [Documentation](https://crmne.github.io/mxc/) · [Getting Started](https://crmne.github.io/mxc/getting-started/) · [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
8
+
9
+ [![Gem Version](https://badge.fury.io/rb/mxc.svg)](https://badge.fury.io/rb/mxc)
10
+ [![CI](https://github.com/crmne/mxc/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/mxc/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
12
+
13
+ </div>
14
+
15
+ ---
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.
18
+
19
+ ```ruby
20
+ result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
21
+ result.stdout
22
+ result.success?
23
+ ```
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.
26
+
27
+ ## Installation
28
+
29
+ ```sh
30
+ bundle add mxc
31
+ ```
32
+
33
+ Precompiled gems cover Linux (x86_64 and ARM64, glibc and musl), macOS (Intel and Apple Silicon), and Windows (x64 and ARM64). On Linux, install Bubblewrap:
34
+
35
+ ```sh
36
+ sudo apt-get install bubblewrap
37
+ ```
38
+
39
+ Commands that use the network also need `slirp4netns`, `iptables`, util-linux, and `/dev/net/tun`. macOS and Windows need nothing extra.
40
+
41
+ ## Usage
42
+
43
+ Run a command. It sees the system's programs read-only, nothing of your home directory or project, and no network:
44
+
45
+ ```ruby
46
+ MXC.run("python3 -c 'print(6 * 7)'").stdout # => "42\n"
47
+ ```
48
+
49
+ One String runs through the shell, like `Kernel#system`. Pass the program and its arguments separately when part of the command comes from users, so each one reaches the program unchanged:
50
+
51
+ ```ruby
52
+ MXC.run("python3", "convert.py", params[:filename])
53
+ ```
54
+
55
+ Build a policy with chainable setters:
56
+
57
+ ```ruby
58
+ sandbox = MXC.sandbox
59
+ .with_readable("app/skills")
60
+ .with_writable(Rails.root.join("tmp/skills"))
61
+ .with_denied("app/skills/secrets")
62
+ .with_network(allow: ["pypi.org", "*.pythonhosted.org"])
63
+ .with_env("LANG" => "C.UTF-8")
64
+ .with_directory("app/skills")
65
+ .with_timeout(30)
66
+
67
+ result = sandbox.run("pip download --no-deps -d /tmp/wheels requests")
68
+ result.blocked # => hosts the allow-list refused
69
+ ```
70
+
71
+ Stream output as it arrives:
72
+
73
+ ```ruby
74
+ sandbox.run("python3 train.py") { |chunk| print chunk.content }
75
+ ```
76
+
77
+ Drive an interactive process:
78
+
79
+ ```ruby
80
+ process = sandbox.spawn("python3 -i -q")
81
+ process.write("print(6 * 7)\n")
82
+ process.close_write
83
+ process.each { |chunk| print chunk.content }
84
+ process.wait # => #<MXC::Result exit_code=0 timed_out=false backend=:bubblewrap>
85
+ ```
86
+
87
+ Name a policy you use in several places:
88
+
89
+ ```ruby
90
+ class SkillSandbox < MXC::Sandbox
91
+ readable "app/skills"
92
+ writable "tmp/skills"
93
+ network false
94
+ timeout 30
95
+ end
96
+
97
+ SkillSandbox.new.run("python3 app/skills/build.py")
98
+ ```
99
+
100
+ Check the machine before you rely on it:
101
+
102
+ ```ruby
103
+ MXC.supported? # starts a real sandbox once and caches the answer
104
+ MXC.availability.remediation # what to change when it cannot
105
+ MXC.supports?(:network_policy) # host allow-lists work here
106
+ MXC.backends # => [#<MXC::Backend name=:bubblewrap ...>]
107
+ ```
108
+
109
+ ## Platforms
110
+
111
+ | Platform | Backend | 0.1.0 |
112
+ | --- | --- | --- |
113
+ | Linux | Bubblewrap | Supported and tested |
114
+ | macOS | Seatbelt | Supported and tested |
115
+ | Windows 11 24H2 and later | ProcessContainer | Supported and tested, without host allow-lists |
116
+
117
+ 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
+
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.
120
+
121
+ ## Documentation
122
+
123
+ * [Getting Started](https://crmne.github.io/mxc/getting-started/)
124
+ * [Running Commands](https://crmne.github.io/mxc/running-commands/)
125
+ * [Policies](https://crmne.github.io/mxc/policies/)
126
+ * [Checking the Host](https://crmne.github.io/mxc/checking-the-host/)
127
+ * [Running in Docker](https://crmne.github.io/mxc/running-in-docker/)
128
+
129
+ ## Development
130
+
131
+ ```sh
132
+ bin/setup # bundle install and compile the extension
133
+ bundle exec rake # compile, tests, Rust unit tests, RuboCop
134
+ ```
135
+
136
+ Tests that start sandboxes skip on machines that cannot. See [CONTRIBUTING.md](CONTRIBUTING.md).
137
+
138
+ ## License
139
+
140
+ MIT. The native extension bundles Microsoft's MXC, also MIT; see [NOTICE](NOTICE).
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Whether this host can start a sandbox, and why not, as returned by MXC.availability.
5
+ #
6
+ # availability = MXC.availability
7
+ # availability.supported? # => false
8
+ # availability.remediation # => "Unprivileged user namespaces are blocked. ..."
9
+ class Availability
10
+ # Returns the backend that was checked.
11
+ attr_reader :backend
12
+
13
+ # Returns why a sandbox cannot start, or nil when it can.
14
+ attr_reader :reason
15
+
16
+ # Returns a suggestion for making the host supported, or nil.
17
+ attr_reader :remediation
18
+
19
+ def self.probe(backend) # :nodoc:
20
+ result = Sandbox.new(backend:, timeout: 10).run('exit 0')
21
+ return new(backend:, supported: true) if result.success?
22
+
23
+ outcome = result.timed_out? ? 'timed out' : "exited with #{result.exit_code}"
24
+ new(backend:, supported: false, reason: "A sandboxed `exit 0` #{outcome}: #{result.stderr.strip}")
25
+ rescue Error => e
26
+ new(backend:, supported: false, reason: e.message, remediation: e.remediation)
27
+ end
28
+
29
+ def initialize(backend:, supported:, reason: nil, remediation: nil) # :nodoc:
30
+ @backend = backend
31
+ @supported = supported
32
+ @reason = reason
33
+ @remediation = remediation
34
+ end
35
+
36
+ # Returns true when a sandbox can start.
37
+ def supported?
38
+ @supported
39
+ end
40
+
41
+ def inspect # :nodoc:
42
+ "#<MXC::Availability backend=#{backend.inspect} supported=#{supported?} reason=#{reason.inspect}>"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A sandbox backend MXC found on this host, as returned by MXC.backends.
5
+ #
6
+ # MXC.backends.first.name # => :bubblewrap
7
+ class Backend
8
+ # Returns the backend's name, such as <tt>:bubblewrap</tt> or <tt>:seatbelt</tt>.
9
+ attr_reader :name
10
+
11
+ # Returns the isolation tier MXC reports for the backend, or nil when it has none.
12
+ attr_reader :tier
13
+
14
+ # Returns the optional capabilities MXC reports for the backend, as Symbols.
15
+ attr_reader :capabilities
16
+
17
+ # Returns MXC's explanations for capabilities the backend lacks on this host.
18
+ attr_reader :warnings
19
+
20
+ def initialize(name:, tier: nil, capabilities: [], warnings: []) # :nodoc:
21
+ @name = name
22
+ @tier = tier
23
+ @capabilities = capabilities.freeze
24
+ @warnings = warnings.freeze
25
+ end
26
+
27
+ # Returns true when the backend reports +capability+, such as <tt>:proxy_enforcement</tt>.
28
+ def supports?(capability)
29
+ capabilities.include?(capability)
30
+ end
31
+
32
+ def inspect # :nodoc:
33
+ "#<MXC::Backend name=#{name.inspect} tier=#{tier.inspect} capabilities=#{capabilities.inspect}>"
34
+ end
35
+ end
36
+ end
data/lib/mxc/chunk.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A piece of output from a running sandbox, as passed to the block of Sandbox#run and Process#each.
5
+ #
6
+ # sandbox.run("python3 build.py") { |chunk| print chunk.content }
7
+ class Chunk
8
+ # Returns the stream the output came from: <tt>:stdout</tt> or <tt>:stderr</tt>.
9
+ attr_reader :stream
10
+
11
+ # Returns the output as a String.
12
+ attr_reader :content
13
+
14
+ def initialize(stream:, content:) # :nodoc:
15
+ @stream = stream
16
+ @content = content
17
+ end
18
+
19
+ # Returns true when the chunk came from standard output.
20
+ def stdout?
21
+ stream == :stdout
22
+ end
23
+
24
+ # Returns true when the chunk came from standard error.
25
+ def stderr?
26
+ stream == :stderr
27
+ end
28
+
29
+ def inspect # :nodoc:
30
+ "#<MXC::Chunk stream=#{stream.inspect} content=#{content.inspect}>"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Global defaults for every sandbox. Change them with MXC.configure.
5
+ class Configuration
6
+ # Returns the default backend, <tt>:auto</tt> unless changed.
7
+ attr_reader :backend
8
+
9
+ # Returns the default timeout in seconds, or nil for none.
10
+ attr_reader :timeout
11
+
12
+ # Returns a Configuration with the built-in defaults.
13
+ def initialize
14
+ @backend = :auto
15
+ @timeout = nil
16
+ end
17
+
18
+ # Sets the default backend: <tt>:auto</tt>, <tt>:bubblewrap</tt>, or <tt>:seatbelt</tt>.
19
+ def backend=(name)
20
+ @backend = Sandbox.backend_name(name)
21
+ end
22
+
23
+ # Sets the default timeout in seconds. Accepts anything that responds to +to_f+, or nil for none.
24
+ def timeout=(seconds)
25
+ @timeout = Sandbox.duration(seconds)
26
+ end
27
+ end
28
+ end
data/lib/mxc/error.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # Raised when MXC cannot start or finish a sandbox.
5
+ #
6
+ # rescue MXC::Error => e
7
+ # e.code # => :backend_error
8
+ # e.remediation # => "Install bubblewrap"
9
+ class Error < StandardError
10
+ # Returns MXC's error code as a Symbol, such as <tt>:backend_unavailable</tt>.
11
+ attr_reader :code
12
+
13
+ # Returns a suggestion for fixing the problem, or nil.
14
+ attr_reader :remediation
15
+
16
+ # Returns the operation that failed, as MXC names it, or nil.
17
+ attr_reader :operation
18
+
19
+ # Returns the operating system's own error code, or nil.
20
+ attr_reader :native_code
21
+
22
+ # Returns a new Error with +message+ and optional details.
23
+ def initialize(message = nil, code: nil, remediation: nil, operation: nil, native_code: nil)
24
+ super(message)
25
+ @code = code
26
+ @remediation = remediation
27
+ @operation = operation
28
+ @native_code = native_code
29
+ end
30
+ end
31
+
32
+ # Raised when this host cannot run the sandbox as configured, such as when Bubblewrap is
33
+ # missing, user namespaces are blocked, or a network policy needs tools that are not installed.
34
+ # Its +remediation+ says what to change.
35
+ class UnsupportedError < Error; end
36
+ end
data/lib/mxc/native.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "mxc/#{RUBY_VERSION[/\d+\.\d+/]}/mxc"
5
+ rescue LoadError
6
+ require 'mxc/mxc'
7
+ end
8
+
9
+ module MXC
10
+ module Native # :nodoc:
11
+ UNSUPPORTED_CODES = %i[backend_unavailable unsupported_containment].freeze
12
+ BACKEND_NAMES = { processcontainer: :process_container }.freeze
13
+ MISSING_BUBBLEWRAP = /bwrap\) is not installed/
14
+ INSTALL_BUBBLEWRAP = 'Install Bubblewrap 0.5.0 or newer (Debian/Ubuntu: apt-get install bubblewrap; ' \
15
+ 'Fedora: dnf install bubblewrap; Arch: pacman -S bubblewrap)'
16
+
17
+ def self.backend_attributes
18
+ backends.map { |attributes| attributes.merge(name: BACKEND_NAMES.fetch(attributes[:name], attributes[:name])) }
19
+ end
20
+
21
+ def self.error(message, code: nil, remediation: nil, **)
22
+ if message.match?(MISSING_BUBBLEWRAP)
23
+ UnsupportedError.new(message, code: :backend_unavailable, remediation: remediation || INSTALL_BUBBLEWRAP, **)
24
+ else
25
+ (UNSUPPORTED_CODES.include?(code) ? UnsupportedError : Error).new(message, code:, remediation:, **)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module Preflight # :nodoc:
5
+ NETWORK_TOOLS = %w[slirp4netns iptables ip6tables iptables-restore ip6tables-restore nsenter unshare].freeze
6
+ TUN = '/dev/net/tun'
7
+ INSTALL = 'Install slirp4netns, iptables and util-linux ' \
8
+ '(Debian/Ubuntu: apt-get install slirp4netns iptables util-linux; ' \
9
+ 'Fedora: dnf install slirp4netns iptables-nft util-linux; ' \
10
+ 'Arch: pacman -S slirp4netns iptables util-linux)'
11
+ WINDOWS_ALLOW_LIST = 'Use network: false, or network: true for unrestricted access. On Windows, MXC confines ' \
12
+ 'a sandbox to a proxy only by also accepting inbound connections to it'
13
+ WINDOWS_DENIED = 'Leave the paths out of readable and writable instead, or run on a Windows build where ' \
14
+ 'MXC.supports?(:filesystem_denied_paths) is true'
15
+
16
+ module_function
17
+
18
+ def check!(network:, denied: [])
19
+ error = problem(network:, denied:)
20
+ raise error if error
21
+ end
22
+
23
+ def problem(network:, denied: [], path: ENV.fetch('PATH', ''), tun: TUN)
24
+ return windows_problem(network, denied) if windows?
25
+ return unless network && linux?
26
+
27
+ missing = NETWORK_TOOLS.reject { |tool| executable?(tool, path) }
28
+ if missing.any?
29
+ return UnsupportedError.new("Network access from a sandbox needs #{missing.join(', ')} on PATH",
30
+ code: :backend_unavailable, remediation: INSTALL)
31
+ end
32
+ return if tun_usable?(tun)
33
+
34
+ UnsupportedError.new("Network access from a sandbox needs a usable #{tun}",
35
+ code: :backend_unavailable, remediation: tun_remediation)
36
+ end
37
+
38
+ def linux?
39
+ RUBY_PLATFORM.include?('linux')
40
+ end
41
+
42
+ def windows?
43
+ Gem.win_platform?
44
+ end
45
+
46
+ def windows_problem(network, denied)
47
+ if network.is_a?(Hash)
48
+ UnsupportedError.new('Host allow-lists are not supported on Windows',
49
+ code: :backend_unavailable, remediation: WINDOWS_ALLOW_LIST)
50
+ elsif denied.any? && !denied_paths_enforced?
51
+ UnsupportedError.new("MXC's AppContainer sandbox on this Windows build does not enforce denied paths",
52
+ code: :backend_unavailable, remediation: WINDOWS_DENIED)
53
+ end
54
+ end
55
+
56
+ def denied_paths_enforced?
57
+ MXC.backends.any? { |backend| backend.supports?(:filesystem_denied_paths) }
58
+ end
59
+
60
+ def executable?(tool, path)
61
+ path.split(File::PATH_SEPARATOR).any? do |dir|
62
+ file = File.join(dir, tool)
63
+ File.file?(file) && File.executable?(file)
64
+ end
65
+ end
66
+
67
+ def tun_usable?(tun)
68
+ File.open(tun, File::RDWR) { true }
69
+ rescue SystemCallError
70
+ false
71
+ end
72
+
73
+ def tun_remediation
74
+ if File.exist?('/.dockerenv') || File.exist?('/run/.containerenv')
75
+ 'Pass the TUN device to the container: docker run --device /dev/net/tun ' \
76
+ '(Kamal: options: { device: /dev/net/tun } on the role)'
77
+ else
78
+ 'Load the tun kernel module: sudo modprobe tun'
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # A command running in a sandbox, as returned by Sandbox#spawn.
5
+ #
6
+ # process = MXC.sandbox.spawn("python3 -i")
7
+ # process.write("print(6 * 7)\n")
8
+ # process.close_write
9
+ # process.each { |chunk| print chunk.content }
10
+ # process.wait # => #<MXC::Result exit_code=0 ...>
11
+ #
12
+ # Output is read in the background from the start, so the command never blocks on a full pipe,
13
+ # and the timeout applies whether or not anything waits.
14
+ class Process
15
+ READ_SIZE = 16 * 1024 # :nodoc:
16
+ STREAMS = %i[stdout stderr].freeze # :nodoc:
17
+
18
+ def initialize(native, backend:) # :nodoc:
19
+ @native = native
20
+ @backend = backend
21
+ @chunks = Thread::Queue.new
22
+ @output = STREAMS.to_h { |stream| [stream, String.new(encoding: Encoding::BINARY)] }
23
+ @lock = Mutex.new
24
+ @streams_lock = Mutex.new
25
+ @open_streams = STREAMS.size
26
+ @readers = STREAMS.map { |stream| background { read(stream) } }
27
+ @waiter = background { @native.wait }
28
+ end
29
+
30
+ # Returns the operating system process id of the sandbox.
31
+ def pid
32
+ @native.pid
33
+ end
34
+
35
+ # Writes +data+ to the command's standard input and returns the number of bytes written.
36
+ def write(data)
37
+ @native.write(data.to_s)
38
+ end
39
+
40
+ # Closes the command's standard input, so it reads end of file.
41
+ def close_write
42
+ @native.close_stdin
43
+ nil
44
+ end
45
+
46
+ # Yields each Chunk of output as it arrives, until the command closes both output streams.
47
+ # Returns an Enumerator without a block.
48
+ def each
49
+ return enum_for(:each) unless block_given?
50
+
51
+ while (chunk = @chunks.pop)
52
+ yield chunk
53
+ end
54
+ self
55
+ end
56
+
57
+ # Stops the command and everything it started.
58
+ def kill
59
+ @native.kill
60
+ nil
61
+ end
62
+
63
+ # Closes standard input, waits for the command to finish, and returns its Result.
64
+ #
65
+ # Raises Error, or UnsupportedError for a host problem, when the sandbox itself could not start.
66
+ def wait
67
+ @lock.synchronize { @result || (@result = finish) }
68
+ end
69
+
70
+ def inspect # :nodoc:
71
+ "#<MXC::Process pid=#{pid} backend=#{@backend.inspect}>"
72
+ end
73
+
74
+ private
75
+
76
+ def background(&)
77
+ Thread.new(&).tap { |thread| thread.report_on_exception = false }
78
+ end
79
+
80
+ def read(stream)
81
+ reader = :"read_#{stream}"
82
+ while (data = @native.public_send(reader, READ_SIZE))
83
+ @output[stream] << data
84
+ @chunks << Chunk.new(stream:, content: data.dup.force_encoding(Encoding.default_external).freeze)
85
+ end
86
+ rescue IOError
87
+ nil
88
+ ensure
89
+ @streams_lock.synchronize { @chunks.close if (@open_streams -= 1).zero? }
90
+ end
91
+
92
+ def finish
93
+ close_write
94
+ outcome = @waiter.value
95
+ @readers.each(&:join)
96
+ outcome = outcome.merge(stdout: @output[:stdout], stderr: @output[:stderr])
97
+ Result.new(**StartupFailure.check!(outcome), backend: @backend)
98
+ end
99
+ end
100
+ end
data/lib/mxc/result.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # The outcome of a command that ran in a sandbox.
5
+ #
6
+ # result = MXC.run("python3 build.py")
7
+ # result.success? # => true
8
+ # result.stdout # => "built 3 files\n"
9
+ class Result
10
+ # Returns everything the command wrote to standard output.
11
+ attr_reader :stdout
12
+
13
+ # Returns everything the command wrote to standard error.
14
+ attr_reader :stderr
15
+
16
+ # Returns the exit code, or nil when the command timed out.
17
+ attr_reader :exit_code
18
+
19
+ # Returns MXC's warnings about the sandbox, as Strings.
20
+ attr_reader :warnings
21
+
22
+ # Returns the <tt>host:port</tt> destinations the network allow-list refused.
23
+ attr_reader :blocked
24
+
25
+ # Returns the backend that ran the command, such as <tt>:bubblewrap</tt>.
26
+ attr_reader :backend
27
+
28
+ def initialize(stdout:, stderr:, exit_code:, timed_out:, warnings:, blocked:, backend:) # :nodoc:
29
+ @stdout = text(stdout)
30
+ @stderr = text(stderr)
31
+ @exit_code = exit_code
32
+ @timed_out = timed_out
33
+ @warnings = warnings.freeze
34
+ @blocked = blocked.freeze
35
+ @backend = backend
36
+ end
37
+
38
+ # Returns true when the command exited with status 0 before its timeout.
39
+ def success?
40
+ !timed_out? && exit_code.zero?
41
+ end
42
+
43
+ # Returns true when the command ran out of time and was stopped.
44
+ def timed_out?
45
+ @timed_out
46
+ end
47
+
48
+ # Returns the result as a Hash.
49
+ def to_h
50
+ { stdout:, stderr:, exit_code:, timed_out: timed_out?, warnings:, blocked:, backend: }
51
+ end
52
+
53
+ def inspect # :nodoc:
54
+ "#<MXC::Result exit_code=#{exit_code.inspect} timed_out=#{timed_out?} backend=#{backend.inspect}>"
55
+ end
56
+
57
+ private
58
+
59
+ def text(bytes)
60
+ bytes.dup.force_encoding(Encoding.default_external).freeze
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ module MXC
6
+ # A sandbox policy: what a command may read, write and reach, and how long it may run.
7
+ #
8
+ # Setters return the sandbox, so they chain, and +nil+ resets a setting to its default:
9
+ #
10
+ # sandbox = MXC.sandbox
11
+ # .with_readable("app/skills")
12
+ # .with_writable(Rails.root.join("tmp"))
13
+ # .with_network(allow: ["pypi.org", "*.pythonhosted.org"])
14
+ # .with_timeout(30)
15
+ # sandbox.run("python3 app/skills/build.py")
16
+ # sandbox.run("python3", "app/skills/convert.py", params[:filename])
17
+ #
18
+ # Subclasses declare the same settings with class macros:
19
+ #
20
+ # class SkillSandbox < MXC::Sandbox
21
+ # readable "app/skills"
22
+ # writable "tmp/skills"
23
+ # network false
24
+ # timeout 30
25
+ # end
26
+ #
27
+ # SkillSandbox.new.run("python3 app/skills/build.py")
28
+ 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
+ HOST_RESOLVER_SOCKETS = %w[/run/systemd/resolve].freeze # :nodoc:
32
+ WINDOWS_ENV = %w[SYSTEMROOT WINDIR SYSTEMDRIVE COMSPEC PATHEXT PATH LOCALAPPDATA TEMP TMP
33
+ PROCESSOR_ARCHITECTURE NUMBER_OF_PROCESSORS OS].freeze # :nodoc:
34
+ BATCH_FILE = /\.(bat|cmd)\z/i # :nodoc:
35
+ MAX_TIMEOUT_MS = (2**32) - 1 # :nodoc:
36
+ DEFAULTS = {
37
+ readable: [], writable: [], denied: [], env: {}, network: false, directory: nil, timeout: nil, backend: nil
38
+ }.freeze # :nodoc:
39
+
40
+ class << self
41
+ # Adds paths every instance can read.
42
+ def readable(*paths) = configure { |sandbox| sandbox.with_readable(*paths) }
43
+
44
+ # Adds paths every instance can read and write.
45
+ def writable(*paths) = configure { |sandbox| sandbox.with_writable(*paths) }
46
+
47
+ # Adds paths every instance hides, even inside readable or writable paths.
48
+ def denied(*paths) = configure { |sandbox| sandbox.with_denied(*paths) }
49
+
50
+ # Sets every instance's network access. Takes the same arguments as #with_network.
51
+ def network(...) = configure { |sandbox| sandbox.with_network(...) }
52
+
53
+ # Adds environment variables for every instance.
54
+ def env(variables) = configure { |sandbox| sandbox.with_env(variables) }
55
+
56
+ # Sets every instance's working directory.
57
+ def directory(path) = configure { |sandbox| sandbox.with_directory(path) }
58
+
59
+ # Sets every instance's timeout in seconds.
60
+ def timeout(seconds) = configure { |sandbox| sandbox.with_timeout(seconds) }
61
+
62
+ # Sets every instance's backend.
63
+ def backend(name) = configure { |sandbox| sandbox.with_backend(name) }
64
+
65
+ def defaults # :nodoc:
66
+ @defaults ||= allocate.tap { |sandbox| sandbox.send(:inherit, self == Sandbox ? nil : superclass.defaults) }
67
+ end
68
+
69
+ def backend_name(name) # :nodoc:
70
+ name = name.to_sym
71
+ return name if BACKENDS.include?(name)
72
+
73
+ raise ArgumentError, "unknown backend #{name.inspect}, expected one of #{BACKENDS.map(&:inspect).join(', ')}"
74
+ end
75
+
76
+ def duration(seconds) # :nodoc:
77
+ return if seconds.nil?
78
+
79
+ value = seconds.to_f
80
+ unless value.positive?
81
+ raise ArgumentError,
82
+ "timeout must be a positive number of seconds, got #{seconds.inspect}"
83
+ end
84
+
85
+ seconds
86
+ end
87
+
88
+ private
89
+
90
+ def configure
91
+ yield defaults
92
+ nil
93
+ end
94
+ end
95
+
96
+ # Returns a new sandbox with the class's defaults, then applies +options+ through
97
+ # 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>.
99
+ #
100
+ # MXC::Sandbox.new(readable: "app/skills", network: { allow: ["pypi.org"] }, timeout: 30)
101
+ def initialize(**options)
102
+ unknown = options.keys - OPTIONS
103
+ raise ArgumentError, "unknown option#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
104
+
105
+ inherit(self.class.defaults)
106
+ options.each { |name, value| apply(name, value) }
107
+ end
108
+
109
+ def initialize_copy(source) # :nodoc:
110
+ super
111
+ @settings = @settings.transform_values(&:dup)
112
+ end
113
+
114
+ # Returns the paths the command can read, as given.
115
+ def readable = @settings[:readable].dup.freeze
116
+
117
+ # Returns the paths the command can read and write, as given.
118
+ def writable = @settings[:writable].dup.freeze
119
+
120
+ # Returns the paths hidden from the command, as given.
121
+ def denied = @settings[:denied].dup.freeze
122
+
123
+ # Returns the environment variables set for the command.
124
+ def env = @settings[:env].dup.freeze
125
+
126
+ # Returns the network access: +false+, +true+, or <tt>{ allow: [...] }</tt> for an allow-list.
127
+ def network = @settings[:network]
128
+
129
+ # Returns the working directory, or nil for the default.
130
+ def directory = @settings[:directory]
131
+
132
+ # Returns the timeout in seconds, falling back to MXC.config.timeout.
133
+ def timeout = @settings[:timeout] || MXC.config.timeout
134
+
135
+ # Returns the backend, falling back to MXC.config.backend.
136
+ def backend = @settings[:backend] || MXC.config.backend
137
+
138
+ # Adds paths the command can read. Accepts Strings and Pathnames; relative paths
139
+ # are expanded from the current directory when the command runs.
140
+ def with_readable(*paths)
141
+ add_paths(:readable, paths)
142
+ end
143
+
144
+ # Adds paths the command can read and write.
145
+ def with_writable(*paths)
146
+ add_paths(:writable, paths)
147
+ end
148
+
149
+ # Adds paths hidden from the command, even inside readable or writable paths.
150
+ def with_denied(*paths)
151
+ add_paths(:denied, paths)
152
+ end
153
+
154
+ # Sets network access. The default, +false+, gives the command no network at all.
155
+ # +true+ allows any destination. <tt>allow:</tt> takes a list of hosts the command
156
+ # may reach over HTTP and HTTPS: <tt>"pypi.org"</tt>, <tt>"*.pythonhosted.org"</tt>
157
+ # (subdomains only), or <tt>"example.com:8443"</tt> for another port.
158
+ #
159
+ # sandbox.with_network(allow: ["pypi.org", "*.pythonhosted.org"])
160
+ def with_network(enabled = true, allow: nil)
161
+ if allow
162
+ raise ArgumentError, 'pass allow: or true/false, not both' unless enabled == true
163
+
164
+ @settings[:network] = { allow: Array(allow).map(&:to_s).freeze }.freeze
165
+ else
166
+ raise ArgumentError, "network must be true, false or nil, got #{enabled.inspect}" unless [true, false,
167
+ nil].include?(enabled)
168
+
169
+ @settings[:network] = enabled || false
170
+ end
171
+ self
172
+ end
173
+
174
+ # Adds environment variables for the command. The sandbox starts from a minimal
175
+ # environment, not the parent's. On Windows, that environment keeps the system
176
+ # variables programs need to start, such as +SYSTEMROOT+ and +PATH+.
177
+ #
178
+ # sandbox.with_env("LANG" => "C.UTF-8")
179
+ def with_env(variables)
180
+ env = @settings[:env]
181
+ variables.nil? ? env.clear : variables.each { |name, value| env[name.to_s] = value.to_s }
182
+ self
183
+ end
184
+
185
+ # Sets the directory the command starts in. The sandbox must be able to read it.
186
+ # Without one, the command starts in <tt>/</tt>, or <tt>%SystemRoot%</tt> on Windows.
187
+ def with_directory(path)
188
+ @settings[:directory] = path && File.path(path)
189
+ self
190
+ end
191
+
192
+ # Sets the timeout in seconds. Accepts anything that responds to +to_f+, such as
193
+ # <tt>30</tt>, <tt>2.5</tt> or <tt>1.minute</tt>.
194
+ def with_timeout(seconds)
195
+ @settings[:timeout] = self.class.duration(seconds)
196
+ self
197
+ end
198
+
199
+ # 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>.
202
+ def with_backend(name)
203
+ @settings[:backend] = name && self.class.backend_name(name)
204
+ self
205
+ end
206
+
207
+ # Runs +command+ and returns its Result once it finishes. Like Kernel#system, one
208
+ # String runs through the platform shell: <tt>sh -c</tt>, or <tt>cmd.exe /d /s /c</tt>
209
+ # on Windows. Two or more Strings are a program and its arguments, and each argument
210
+ # reaches the program unchanged, without a shell.
211
+ #
212
+ # With a block, yields each Chunk of output as it arrives.
213
+ #
214
+ # sandbox.run("python3 build.py && ls dist")
215
+ # sandbox.run("python3", "convert.py", params[:filename])
216
+ # sandbox.run("python3", "build.py") { |chunk| print chunk.content }
217
+ #
218
+ # Raises ArgumentError without a command or when given an Array. On Windows, it also
219
+ # raises ArgumentError for a program and arguments that start a +.bat+ or +.cmd+ file,
220
+ # because Windows runs those through +cmd.exe+, which splits the arguments again.
221
+ def run(*command, &)
222
+ return stream(*command, &) if block_given?
223
+
224
+ outcome = StartupFailure.check!(Native.run(command_line(command), request))
225
+ Result.new(**outcome, backend: resolved_backend)
226
+ end
227
+
228
+ # Starts +command+ and returns its Process without waiting for it. Takes the command
229
+ # the same way as #run.
230
+ #
231
+ # sandbox.spawn("python3", "-i", "-q")
232
+ def spawn(*command)
233
+ Process.new(Native.spawn(command_line(command), request), backend: resolved_backend)
234
+ end
235
+
236
+ # Returns true when this host can run this sandbox, including its network policy.
237
+ def supported?
238
+ availability.supported?
239
+ end
240
+
241
+ # Returns the Availability of this sandbox on this host, with the reason and
242
+ # remediation when it cannot run.
243
+ def availability
244
+ problem = Preflight.problem(network:, denied:)
245
+ return MXC.availability(backend:) unless problem
246
+
247
+ Availability.new(backend:, supported: false, reason: problem.message, remediation: problem.remediation)
248
+ end
249
+
250
+ def inspect # :nodoc:
251
+ "#<#{self.class.name || 'MXC::Sandbox'} backend=#{backend.inspect} network=#{network.inspect} " \
252
+ "readable=#{readable.inspect} writable=#{writable.inspect} timeout=#{timeout.inspect}>"
253
+ end
254
+
255
+ protected
256
+
257
+ attr_reader :settings # :nodoc:
258
+
259
+ private
260
+
261
+ def inherit(source)
262
+ @settings = source ? source.settings.transform_values(&:dup) : DEFAULTS.transform_values(&:dup)
263
+ end
264
+
265
+ def apply(name, value)
266
+ case name
267
+ when :readable, :writable, :denied then public_send(:"with_#{name}", *Array(value))
268
+ when :network then value.is_a?(Hash) ? with_network(**value) : with_network(value)
269
+ else public_send(:"with_#{name}", value)
270
+ end
271
+ end
272
+
273
+ def add_paths(name, paths)
274
+ list = @settings[name]
275
+ paths == [nil] ? list.clear : list.concat(paths.flatten.map { |path| File.path(path) })
276
+ self
277
+ end
278
+
279
+ def stream(*command, &)
280
+ process = spawn(*command)
281
+ finished = false
282
+ process.each(&)
283
+ finished = true
284
+ process.wait
285
+ ensure
286
+ process&.kill unless finished
287
+ end
288
+
289
+ def command_line(command)
290
+ arguments = command_arguments(command)
291
+ return command_line_for_windows(arguments) if Preflight.windows?
292
+
293
+ arguments.one? ? arguments.first : Shellwords.join(arguments)
294
+ end
295
+
296
+ def command_arguments(command)
297
+ raise ArgumentError, 'no command given' if command.empty?
298
+ if command.any?(Array)
299
+ raise ArgumentError, 'pass the program and its arguments as separate Strings, ' \
300
+ 'as in run("python3", "build.py"), not as an Array'
301
+ end
302
+
303
+ command.map { |argument| String.try_convert(argument) || File.path(argument) }
304
+ end
305
+
306
+ def command_line_for_windows(arguments)
307
+ return %(cmd.exe /d /s /c "#{arguments.first}") if arguments.one?
308
+
309
+ if arguments.first.match?(BATCH_FILE)
310
+ raise ArgumentError, 'Windows runs batch files through cmd.exe, which does not keep arguments apart. ' \
311
+ 'Run them with one String and quote untrusted input yourself'
312
+ end
313
+
314
+ arguments.map { |argument| WindowsArguments.quote(argument) }.join(' ')
315
+ end
316
+
317
+ def resolved_backend
318
+ return backend unless backend == :auto
319
+ return :process_container if Preflight.windows?
320
+
321
+ Preflight.linux? ? :bubblewrap : :seatbelt
322
+ end
323
+
324
+ def request
325
+ Preflight.check!(network:, denied:)
326
+ {
327
+ readonly: expand(readable),
328
+ readwrite: expand(writable),
329
+ denied: expand(denied) | hidden_resolver_sockets,
330
+ environment:,
331
+ working_directory:,
332
+ timeout_ms: timeout && [(timeout.to_f * 1000).ceil, MAX_TIMEOUT_MS].min,
333
+ backend:,
334
+ network: network == true,
335
+ allow: network.is_a?(Hash) ? network[:allow] : nil
336
+ }
337
+ end
338
+
339
+ def expand(paths)
340
+ paths.map { |path| native_path(File.expand_path(path)) }
341
+ end
342
+
343
+ def native_path(path)
344
+ Preflight.windows? ? path.tr('/', '\\') : path
345
+ end
346
+
347
+ def working_directory
348
+ return native_path(File.expand_path(directory)) if directory
349
+
350
+ ENV.fetch('SystemRoot', nil) if Preflight.windows?
351
+ end
352
+
353
+ def environment
354
+ return env unless Preflight.windows?
355
+
356
+ system = ENV.to_h.select { |name, _| WINDOWS_ENV.include?(name.upcase) }
357
+ system.reject { |name, _| env.keys.any? { |own| own.casecmp?(name) } }.merge(env)
358
+ end
359
+
360
+ def hidden_resolver_sockets
361
+ return [] if network == true
362
+
363
+ HOST_RESOLVER_SOCKETS.select { |path| File.exist?(path) }
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module StartupFailure # :nodoc:
5
+ DOCKER_GUIDE = 'https://crmne.github.io/mxc/running-in-docker/'
6
+ NAMESPACES = Regexp.union(/No permissions to create a?\s*new namespace/i, /Creating new namespace failed/i,
7
+ /setting up uid map/i, /unshare.*Operation not permitted/i)
8
+ PROC = /Can't mount proc|mount proc on/i
9
+ HOST_PROBLEMS = {
10
+ NAMESPACES => 'Unprivileged user namespaces are blocked. On a host, enable kernel.unprivileged_userns_clone=1 ' \
11
+ 'and, on Ubuntu 23.10 and later, allow bwrap in AppArmor ' \
12
+ '(kernel.apparmor_restrict_unprivileged_userns). ' \
13
+ "In Docker, run with a seccomp profile that allows namespaces: #{DOCKER_GUIDE}",
14
+ PROC => 'bwrap cannot mount a fresh /proc because the container masks /proc paths. ' \
15
+ "In Docker, see #{DOCKER_GUIDE}"
16
+ }.freeze
17
+ CHDIR = /Can't chdir/i
18
+
19
+ module_function
20
+
21
+ def detect(outcome)
22
+ return unless outcome[:exit_code] == 1 && outcome[:stdout].to_s.empty?
23
+
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) }
29
+ return UnsupportedError.new(message, code: :backend_unavailable, remediation: hint, operation: 'bwrap') if hint
30
+
31
+ remediation = 'Make the directory readable or writable in the sandbox' if stderr.match?(CHDIR)
32
+ Error.new(message, code: :backend_error, remediation:, operation: 'bwrap')
33
+ end
34
+
35
+ def check!(outcome)
36
+ error = detect(outcome)
37
+ raise error if error
38
+
39
+ outcome
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ # The gem version.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MXC
4
+ module WindowsArguments # :nodoc:
5
+ SPECIAL = /[\s"]/
6
+
7
+ module_function
8
+
9
+ # Quotes +argument+ so CommandLineToArgvW and the C runtime read it back unchanged.
10
+ def quote(argument)
11
+ return '""' if argument.empty?
12
+ return argument unless argument.match?(SPECIAL)
13
+
14
+ quoted = argument.gsub(/(\\*)"/) { "#{Regexp.last_match(1) * 2}\\\"" }
15
+ "\"#{quoted.sub(/(\\+)\z/) { Regexp.last_match(1) * 2 }}\""
16
+ end
17
+ end
18
+ end
data/lib/mxc.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mxc/version'
4
+ require 'mxc/error'
5
+ require 'mxc/configuration'
6
+ require 'mxc/native'
7
+ require 'mxc/preflight'
8
+ require 'mxc/startup_failure'
9
+ require 'mxc/chunk'
10
+ require 'mxc/result'
11
+ require 'mxc/backend'
12
+ require 'mxc/availability'
13
+ require 'mxc/process'
14
+ require 'mxc/windows_arguments'
15
+ require 'mxc/sandbox'
16
+
17
+ # Runs commands in sandboxes through Microsoft's MXC.
18
+ #
19
+ # result = MXC.run("python3 build.py", readable: "app/skills", writable: "tmp/out", timeout: 30)
20
+ # result.stdout
21
+ module MXC
22
+ @availability = {}
23
+ @availability_lock = Mutex.new
24
+
25
+ class << self
26
+ # Returns the global Configuration.
27
+ def config
28
+ @config ||= Configuration.new
29
+ end
30
+
31
+ # Yields the global Configuration to change the defaults every sandbox starts from.
32
+ #
33
+ # MXC.configure do |config|
34
+ # config.timeout = 60
35
+ # end
36
+ def configure
37
+ yield config
38
+ end
39
+
40
+ # Returns a new Sandbox, optionally configured with the same keywords as Sandbox.new.
41
+ def sandbox(**)
42
+ Sandbox.new(**)
43
+ end
44
+
45
+ # Runs +command+ in a new sandbox and returns its Result. Takes the command the same
46
+ # way as Sandbox#run, one String for the shell or a program and its arguments, and the
47
+ # same keywords as Sandbox.new. A block receives each Chunk of output as it arrives.
48
+ #
49
+ # MXC.run("python3 build.py", readable: "app/skills", timeout: 30)
50
+ # MXC.run("python3", "convert.py", params[:filename], readable: "app/skills")
51
+ def run(*command, **, &)
52
+ sandbox(**).run(*command, &)
53
+ end
54
+
55
+ # Starts +command+ in a new sandbox and returns its Process. Takes the command the
56
+ # same way as Sandbox#run, and the same keywords as Sandbox.new.
57
+ #
58
+ # MXC.spawn("python3", "-i", "-q", timeout: 60)
59
+ def spawn(*command, **)
60
+ sandbox(**).spawn(*command)
61
+ end
62
+
63
+ # Returns true when this host can start a sandbox with +backend+.
64
+ #
65
+ # The answer comes from starting one real sandbox, once per backend and process.
66
+ def supported?(backend: config.backend)
67
+ availability(backend:).supported?
68
+ end
69
+
70
+ # Returns the Availability of +backend+ on this host, with the reason and remediation
71
+ # when it is unsupported. Pass <tt>refresh: true</tt> to probe the host again.
72
+ def availability(backend: config.backend, refresh: false)
73
+ backend = Sandbox.backend_name(backend)
74
+ @availability_lock.synchronize do
75
+ @availability.delete(backend) if refresh
76
+ @availability[backend] ||= Availability.probe(backend)
77
+ end
78
+ end
79
+
80
+ # Returns the sandbox backends MXC finds on this host, as Backend objects.
81
+ def backends
82
+ Native.backend_attributes.map { |attributes| Backend.new(**attributes) }
83
+ end
84
+
85
+ # Returns true when this host supports +capability+ with the configured backend.
86
+ #
87
+ # +capability+ is <tt>:network</tt> for unrestricted network access,
88
+ # <tt>:network_policy</tt> for host allow-lists, or any capability a Backend reports.
89
+ def supports?(capability)
90
+ return false unless supported?
91
+
92
+ case capability
93
+ when :network then Preflight.problem(network: true).nil?
94
+ when :network_policy then Preflight.problem(network: { allow: [] }).nil? && proxy_enforcement?
95
+ else configured_backends.any? { |backend| backend.supports?(capability) }
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def proxy_enforcement?
102
+ !Preflight.linux? || configured_backends.any? { |backend| backend.supports?(:proxy_enforcement) }
103
+ end
104
+
105
+ def configured_backends
106
+ backends.select { |backend| config.backend == :auto || backend.name == config.backend }
107
+ end
108
+ end
109
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mxc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: x86_64-darwin
6
+ authors:
7
+ - Carmine Paolino
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: An unofficial Ruby binding to Microsoft's MXC. Run commands in Bubblewrap,
14
+ Seatbelt or Windows ProcessContainer sandboxes with filesystem, network, environment
15
+ and timeout policies.
16
+ email:
17
+ - carmine@paolino.me
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - NOTICE
24
+ - README.md
25
+ - lib/mxc.rb
26
+ - lib/mxc/3.2/mxc.bundle
27
+ - lib/mxc/3.3/mxc.bundle
28
+ - lib/mxc/3.4/mxc.bundle
29
+ - lib/mxc/4.0/mxc.bundle
30
+ - lib/mxc/availability.rb
31
+ - lib/mxc/backend.rb
32
+ - lib/mxc/chunk.rb
33
+ - lib/mxc/configuration.rb
34
+ - lib/mxc/error.rb
35
+ - lib/mxc/native.rb
36
+ - lib/mxc/preflight.rb
37
+ - lib/mxc/process.rb
38
+ - lib/mxc/result.rb
39
+ - lib/mxc/sandbox.rb
40
+ - lib/mxc/startup_failure.rb
41
+ - lib/mxc/version.rb
42
+ - lib/mxc/windows_arguments.rb
43
+ homepage: https://crmne.github.io/mxc/
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://crmne.github.io/mxc/
48
+ source_code_uri: https://github.com/crmne/mxc
49
+ bug_tracker_uri: https://github.com/crmne/mxc/issues
50
+ changelog_uri: https://github.com/crmne/mxc/releases
51
+ funding_uri: https://github.com/sponsors/crmne
52
+ rubygems_mfa_required: 'true'
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ - - "<"
63
+ - !ruby/object:Gem::Version
64
+ version: 4.1.dev
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.5.23
72
+ signing_key:
73
+ specification_version: 4
74
+ summary: Run untrusted commands in OS sandboxes from Ruby, through Microsoft's MXC.
75
+ test_files: []