hermetic 0.1.0
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/CHANGELOG.md +25 -0
- data/EXECUTOR.md +123 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/lib/hermetic/backends/base.rb +52 -0
- data/lib/hermetic/backends/docker.rb +104 -0
- data/lib/hermetic/backends/firecracker.rb +114 -0
- data/lib/hermetic/backends/gvisor.rb +23 -0
- data/lib/hermetic/backends/hosted/e2b.rb +96 -0
- data/lib/hermetic/backends/hosted.rb +27 -0
- data/lib/hermetic/backends/null.rb +19 -0
- data/lib/hermetic/credentials.rb +41 -0
- data/lib/hermetic/errors.rb +34 -0
- data/lib/hermetic/limits.rb +44 -0
- data/lib/hermetic/network_policy.rb +52 -0
- data/lib/hermetic/remote.rb +96 -0
- data/lib/hermetic/request.rb +127 -0
- data/lib/hermetic/result.rb +15 -0
- data/lib/hermetic/silas.rb +23 -0
- data/lib/hermetic/transport/http.rb +52 -0
- data/lib/hermetic/transport/subprocess.rb +64 -0
- data/lib/hermetic/version.rb +3 -0
- data/lib/hermetic.rb +67 -0
- metadata +99 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
module Transport
|
|
5
|
+
# Host-side process runner: spawns an argv, pipes stdin, and enforces the
|
|
6
|
+
# wall clock with a reaper (never trusts the guest to exit — the pattern
|
|
7
|
+
# ported from Silas's Docker adapter). Returns
|
|
8
|
+
# [stdout, stderr, exit_status, timed_out]; a timeout maps to exit 124
|
|
9
|
+
# (coreutils convention, matches Silas TIMEOUT_EXIT). The low-level
|
|
10
|
+
# capture is injectable so the tuple/timeout contract is unit-testable
|
|
11
|
+
# with no real processes.
|
|
12
|
+
class Subprocess
|
|
13
|
+
TIMEOUT_EXIT = 124
|
|
14
|
+
|
|
15
|
+
def initialize(capture: nil)
|
|
16
|
+
@capture = capture || method(:popen_capture)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(argv, stdin: nil, timeout: 30)
|
|
20
|
+
unless argv.is_a?(Array) && !argv.empty? && argv.all?(String)
|
|
21
|
+
raise ConfigError, "argv must be a non-empty Array of Strings, got #{argv.inspect}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
out, err, status, timed_out = @capture.call(argv, stdin: stdin, timeout: timeout)
|
|
25
|
+
return [ out, err, status, false ] unless timed_out
|
|
26
|
+
|
|
27
|
+
[ "", "hermetic: command timed out after #{timeout}s", TIMEOUT_EXIT, true ]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def popen_capture(argv, stdin:, timeout:)
|
|
33
|
+
Open3.popen3(*argv) do |in_io, out_io, err_io, wait|
|
|
34
|
+
timed_out = false
|
|
35
|
+
reaper = Thread.new do
|
|
36
|
+
unless wait.join(timeout)
|
|
37
|
+
timed_out = true
|
|
38
|
+
begin
|
|
39
|
+
Process.kill("KILL", wait.pid)
|
|
40
|
+
rescue Errno::ESRCH
|
|
41
|
+
nil # exited on the deadline itself; wall clock was still blown
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
out_reader = Thread.new { out_io.read }
|
|
47
|
+
err_reader = Thread.new { err_io.read }
|
|
48
|
+
begin
|
|
49
|
+
in_io.write(stdin) if stdin
|
|
50
|
+
rescue Errno::EPIPE
|
|
51
|
+
nil # guest exited without reading stdin; its exit status tells the story
|
|
52
|
+
end
|
|
53
|
+
in_io.close
|
|
54
|
+
|
|
55
|
+
status = wait.value
|
|
56
|
+
reaper.join
|
|
57
|
+
[ out_reader.value, err_reader.value, status.exitstatus || (128 + status.termsig.to_i), timed_out ]
|
|
58
|
+
end
|
|
59
|
+
rescue Errno::ENOENT => e
|
|
60
|
+
raise TransportError, "#{argv.first.inspect} not found on PATH (#{e.message})"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/hermetic.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "hermetic/version"
|
|
2
|
+
require "hermetic/errors"
|
|
3
|
+
require "hermetic/result"
|
|
4
|
+
require "hermetic/limits"
|
|
5
|
+
require "hermetic/network_policy"
|
|
6
|
+
require "hermetic/credentials"
|
|
7
|
+
require "hermetic/request"
|
|
8
|
+
require "hermetic/transport/subprocess"
|
|
9
|
+
require "hermetic/transport/http"
|
|
10
|
+
require "hermetic/backends/base"
|
|
11
|
+
require "hermetic/backends/null"
|
|
12
|
+
|
|
13
|
+
# hermetic — the missing Ruby sandbox. One `run` interface, swappable
|
|
14
|
+
# isolation backends, designed to run off the app host so a guest escape
|
|
15
|
+
# can't reach your secrets. Every constructor returns a plain object
|
|
16
|
+
# implementing run/enabled?, so `config.sandbox = Hermetic.gvisor(...)`
|
|
17
|
+
# drops into Silas unchanged.
|
|
18
|
+
#
|
|
19
|
+
# Backends beyond Null are required lazily — constructing one you haven't
|
|
20
|
+
# shipped/installed fails at the constructor, not at boot.
|
|
21
|
+
module Hermetic
|
|
22
|
+
# Options that shape the client-side Request (or the wire itself) and so
|
|
23
|
+
# stay on the Remote client when `executor:` reroutes a local constructor;
|
|
24
|
+
# everything else is the executor's to construct with (spec §3).
|
|
25
|
+
CLIENT_OPTS = %i[limits credentials credential_mode env_allowlist transport].freeze
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def null = Backends::Null.new
|
|
29
|
+
|
|
30
|
+
def docker(**opts) = backend(:docker, :Docker, "hermetic/backends/docker", **opts)
|
|
31
|
+
|
|
32
|
+
def gvisor(**opts) = backend(:gvisor, :Gvisor, "hermetic/backends/gvisor", **opts)
|
|
33
|
+
|
|
34
|
+
def firecracker(**opts) = backend(:firecracker, :Firecracker, "hermetic/backends/firecracker", **opts)
|
|
35
|
+
|
|
36
|
+
def hosted(provider, **opts)
|
|
37
|
+
require "hermetic/backends/hosted"
|
|
38
|
+
Backends::Hosted.build(provider, **opts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Off-host executor client (trust :remote): the isolation runs on a
|
|
42
|
+
# dedicated sandbox host; this process holds only the URL + token.
|
|
43
|
+
def wrap(executor:, **opts)
|
|
44
|
+
require "hermetic/remote"
|
|
45
|
+
Remote.new(executor: executor, **opts)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Any local constructor takes `executor:` (+ `token:`) and becomes a
|
|
51
|
+
# Remote client for that isolation kind — same backend, off the app host:
|
|
52
|
+
# Hermetic.gvisor(image: "ruby:3.3-slim", executor: "https://sbx:8443", token: ...)
|
|
53
|
+
# Request-shaping options stay client-side; the rest ship as
|
|
54
|
+
# backend_options for the executor to construct with.
|
|
55
|
+
def backend(kind, const, path, **opts)
|
|
56
|
+
if opts.key?(:executor)
|
|
57
|
+
return wrap(backend: kind,
|
|
58
|
+
executor: opts[:executor], token: opts[:token],
|
|
59
|
+
backend_options: opts.except(:executor, :token, *CLIENT_OPTS),
|
|
60
|
+
**opts.slice(*CLIENT_OPTS))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
require path
|
|
64
|
+
Backends.const_get(const).new(**opts)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hermetic
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Daniel St Paul
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rspec
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.13'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '3.13'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: Pick your isolation strength (gVisor, a Firecracker microVM, a hosted
|
|
41
|
+
sandbox, or hardened Docker) behind one `run` call, and run it off your app host
|
|
42
|
+
so an escape can't reach your database or secrets. Zero runtime dependencies — stdlib
|
|
43
|
+
only.
|
|
44
|
+
email:
|
|
45
|
+
- daniel@zerogravity.co.uk
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- EXECUTOR.md
|
|
52
|
+
- LICENSE
|
|
53
|
+
- README.md
|
|
54
|
+
- lib/hermetic.rb
|
|
55
|
+
- lib/hermetic/backends/base.rb
|
|
56
|
+
- lib/hermetic/backends/docker.rb
|
|
57
|
+
- lib/hermetic/backends/firecracker.rb
|
|
58
|
+
- lib/hermetic/backends/gvisor.rb
|
|
59
|
+
- lib/hermetic/backends/hosted.rb
|
|
60
|
+
- lib/hermetic/backends/hosted/e2b.rb
|
|
61
|
+
- lib/hermetic/backends/null.rb
|
|
62
|
+
- lib/hermetic/credentials.rb
|
|
63
|
+
- lib/hermetic/errors.rb
|
|
64
|
+
- lib/hermetic/limits.rb
|
|
65
|
+
- lib/hermetic/network_policy.rb
|
|
66
|
+
- lib/hermetic/remote.rb
|
|
67
|
+
- lib/hermetic/request.rb
|
|
68
|
+
- lib/hermetic/result.rb
|
|
69
|
+
- lib/hermetic/silas.rb
|
|
70
|
+
- lib/hermetic/transport/http.rb
|
|
71
|
+
- lib/hermetic/transport/subprocess.rb
|
|
72
|
+
- lib/hermetic/version.rb
|
|
73
|
+
homepage: https://github.com/danielstpaul/hermetic
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://github.com/danielstpaul/hermetic
|
|
78
|
+
source_code_uri: https://github.com/danielstpaul/hermetic
|
|
79
|
+
changelog_uri: https://github.com/danielstpaul/hermetic/blob/main/CHANGELOG.md
|
|
80
|
+
rubygems_mfa_required: 'true'
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.2'
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 3.6.9
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: The missing Ruby sandbox — real isolation for untrusted / model-generated
|
|
98
|
+
code.
|
|
99
|
+
test_files: []
|