mxc 0.1.0-x86_64-linux-musl
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 +7 -0
- data/LICENSE +21 -0
- data/NOTICE +34 -0
- data/README.md +140 -0
- data/lib/mxc/3.2/mxc.so +0 -0
- data/lib/mxc/3.3/mxc.so +0 -0
- data/lib/mxc/3.4/mxc.so +0 -0
- data/lib/mxc/4.0/mxc.so +0 -0
- data/lib/mxc/availability.rb +45 -0
- data/lib/mxc/backend.rb +36 -0
- data/lib/mxc/chunk.rb +33 -0
- data/lib/mxc/configuration.rb +28 -0
- data/lib/mxc/error.rb +36 -0
- data/lib/mxc/native.rb +29 -0
- data/lib/mxc/preflight.rb +82 -0
- data/lib/mxc/process.rb +100 -0
- data/lib/mxc/result.rb +63 -0
- data/lib/mxc/sandbox.rb +366 -0
- data/lib/mxc/startup_failure.rb +42 -0
- data/lib/mxc/version.rb +6 -0
- data/lib/mxc/windows_arguments.rb +18 -0
- data/lib/mxc.rb +109 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3d92d20b908054c2e9efaa875a2ed37fdfcbf47c2cb5126e658c1ad348074239
|
|
4
|
+
data.tar.gz: b46625d02e842e6cb75711583e5876dde8393408c60f477b523a7c241869569d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e950f61d89ff775076ce1923f8801deeecd5f8a655d3470989e6fa1f0b8a0ea96dd83e668f8181915235118eb23731f5793abcb3983ccbffbe267ab7909774e1
|
|
7
|
+
data.tar.gz: 7f6a86cfadd3a2b97e041407a9d0342276b3a246bf48d6699a42eba037974f814f08eadeee1ee80a7682ebb5fa6d04ecf5c0281e95c5b1f3b20e9463b1bd5454
|
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
|
+
[](https://badge.fury.io/rb/mxc)
|
|
10
|
+
[](https://github.com/crmne/mxc/actions/workflows/ci.yml)
|
|
11
|
+
[](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).
|
data/lib/mxc/3.2/mxc.so
ADDED
|
Binary file
|
data/lib/mxc/3.3/mxc.so
ADDED
|
Binary file
|
data/lib/mxc/3.4/mxc.so
ADDED
|
Binary file
|
data/lib/mxc/4.0/mxc.so
ADDED
|
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
|
data/lib/mxc/backend.rb
ADDED
|
@@ -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
|
data/lib/mxc/process.rb
ADDED
|
@@ -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
|
data/lib/mxc/sandbox.rb
ADDED
|
@@ -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
|
data/lib/mxc/version.rb
ADDED
|
@@ -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-linux-musl
|
|
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.so
|
|
27
|
+
- lib/mxc/3.3/mxc.so
|
|
28
|
+
- lib/mxc/3.4/mxc.so
|
|
29
|
+
- lib/mxc/4.0/mxc.so
|
|
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: 3.3.22
|
|
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: []
|