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,96 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
module Backends
|
|
5
|
+
module Hosted
|
|
6
|
+
# E2B (e2b.dev) — a vendor microVM per run. trust :vendor, off-host by
|
|
7
|
+
# construction: a guest escape lands in E2B's cloud, not next to the
|
|
8
|
+
# app's secrets.
|
|
9
|
+
#
|
|
10
|
+
# BEST-EFFORT WIRE SHAPE (spec §6.4): BASE_URL, the endpoint path, and
|
|
11
|
+
# the request/response bodies below are a design-time approximation of
|
|
12
|
+
# E2B's create-and-run API. Pin them against the live API via the
|
|
13
|
+
# :hosted smoke layer (spec §5) before first real use.
|
|
14
|
+
class E2B < Base
|
|
15
|
+
BASE_URL = "https://api.e2b.dev"
|
|
16
|
+
|
|
17
|
+
def initialize(api_key:, template: "base", base_url: BASE_URL, transport: nil, **opts)
|
|
18
|
+
raise ConfigError, "api_key is required for Hosted::E2B" if api_key.to_s.strip.empty?
|
|
19
|
+
|
|
20
|
+
super(trust: :vendor, **opts)
|
|
21
|
+
@api_key = api_key
|
|
22
|
+
@template = template
|
|
23
|
+
@base_url = base_url.to_s.chomp("/")
|
|
24
|
+
@transport = transport || Transport::Http.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def backend_name = :e2b
|
|
28
|
+
|
|
29
|
+
# Pure: one run as an HTTP request spec — unit-testable with zero
|
|
30
|
+
# infra. Box credentials are already resolved into clean_env (:env
|
|
31
|
+
# mode), so the model-visible command never carries a raw token.
|
|
32
|
+
def create_and_run_spec(request)
|
|
33
|
+
{
|
|
34
|
+
method: :post,
|
|
35
|
+
url: "#{@base_url}/v1/sandboxes/exec",
|
|
36
|
+
headers: {
|
|
37
|
+
"X-API-Key" => @api_key,
|
|
38
|
+
"Content-Type" => "application/json",
|
|
39
|
+
"Idempotency-Key" => request.idempotency_key
|
|
40
|
+
},
|
|
41
|
+
body: JSON.generate(
|
|
42
|
+
template: @template,
|
|
43
|
+
command: request.exec_string,
|
|
44
|
+
stdin: request.stdin,
|
|
45
|
+
files: request.files,
|
|
46
|
+
env: request.clean_env,
|
|
47
|
+
network: { egress: request.network.egress },
|
|
48
|
+
timeout: request.timeout,
|
|
49
|
+
limits: request.limits.to_h
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# In-doubt discipline (spec §7 risk 2, v1: no automatic retry):
|
|
57
|
+
# wire failure / 5xx -> the run may have executed vendor-side, raise
|
|
58
|
+
# in_doubt; 4xx -> the vendor refused before starting, plain failure.
|
|
59
|
+
# A guest failure is never an exception — it's a non-zero Result.
|
|
60
|
+
def execute(request)
|
|
61
|
+
response = begin
|
|
62
|
+
@transport.call(create_and_run_spec(request))
|
|
63
|
+
rescue TransportError => e
|
|
64
|
+
raise TransportError.new(
|
|
65
|
+
"E2B unreachable or response lost (in doubt, idempotency_key=#{request.idempotency_key}): #{e.message}",
|
|
66
|
+
in_doubt: true
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless response.success?
|
|
71
|
+
raise TransportError.new(
|
|
72
|
+
"E2B returned HTTP #{response.status} (idempotency_key=#{request.idempotency_key}): " \
|
|
73
|
+
"#{response.body.to_s[0, 200]}",
|
|
74
|
+
in_doubt: response.status >= 500
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
to_tuple(response.json)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
raise TransportError.new(
|
|
81
|
+
"E2B returned unparseable JSON (in doubt, idempotency_key=#{request.idempotency_key})",
|
|
82
|
+
in_doubt: true
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Vendor payload -> the execute tuple Base assembles into a Result.
|
|
87
|
+
# Expected shape (best-effort, pinned with the endpoint):
|
|
88
|
+
# { "stdout": "", "stderr": "", "exitCode": 0, "timedOut": false }
|
|
89
|
+
def to_tuple(payload)
|
|
90
|
+
[ payload["stdout"].to_s, payload["stderr"].to_s,
|
|
91
|
+
payload["exitCode"].to_i, !!payload["timedOut"] ]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "hermetic/backends/hosted/e2b"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
module Backends
|
|
5
|
+
# Factory for vendor-cloud sandboxes — off-host by construction (spec §3):
|
|
6
|
+
# the guest lives in the vendor's cloud and this process holds only an
|
|
7
|
+
# API key. E2B ships in v1; Daytona/Modal are named but deferred so the
|
|
8
|
+
# constructor fails with the roadmap, not a NameError.
|
|
9
|
+
module Hosted
|
|
10
|
+
PROVIDERS = { e2b: :E2B }.freeze
|
|
11
|
+
DEFERRED = %i[daytona modal].freeze
|
|
12
|
+
|
|
13
|
+
def self.build(provider, **opts)
|
|
14
|
+
key = provider.to_s.downcase.to_sym
|
|
15
|
+
if DEFERRED.include?(key)
|
|
16
|
+
raise ConfigError, "hosted provider #{key.inspect} is deferred (v1.1) — :e2b is the v1 provider"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
const = PROVIDERS.fetch(key) do
|
|
20
|
+
raise ConfigError,
|
|
21
|
+
"unknown hosted provider #{provider.inspect} (known: #{PROVIDERS.keys.inspect}, deferred: #{DEFERRED.inspect})"
|
|
22
|
+
end
|
|
23
|
+
const_get(const).new(**opts)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
module Backends
|
|
3
|
+
# Disabled: code execution is off. Fails loud on run — a silent no-op
|
|
4
|
+
# would look like a sandbox while providing none.
|
|
5
|
+
class Null < Base
|
|
6
|
+
def initialize
|
|
7
|
+
super(trust: :none)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def enabled? = false
|
|
11
|
+
|
|
12
|
+
def run(*, **)
|
|
13
|
+
raise DisabledError,
|
|
14
|
+
"code execution is disabled (Null backend) — construct a real one: " \
|
|
15
|
+
"Hermetic.hosted / .gvisor / .firecracker / .docker"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
# Named secrets injected into the guest at exec time — the model-visible
|
|
3
|
+
# command never contains a raw token. Values are callables resolved lazily
|
|
4
|
+
# per run, so short-lived tokens are minted fresh and never cached on the
|
|
5
|
+
# box. :env mode ships in v1; :proxy (guest gets no token at all) is v1.1.
|
|
6
|
+
class Credentials
|
|
7
|
+
MODES = %i[env proxy].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :mode
|
|
10
|
+
|
|
11
|
+
def self.build(value, mode: :env)
|
|
12
|
+
case value
|
|
13
|
+
when nil then new({}, mode: mode)
|
|
14
|
+
when Credentials then value
|
|
15
|
+
when Hash then new(value, mode: mode)
|
|
16
|
+
else raise ConfigError, "credentials must be a Hash of NAME => value-or-callable, got #{value.inspect}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(map = {}, mode: :env)
|
|
21
|
+
raise ConfigError, "unknown credential_mode #{mode.inspect} (known: #{MODES.inspect})" unless MODES.include?(mode)
|
|
22
|
+
raise ConfigError, "credential_mode :proxy is deferred (v1.1) — use :env" if mode == :proxy
|
|
23
|
+
|
|
24
|
+
@map = map.to_h.transform_keys(&:to_s).freeze
|
|
25
|
+
@mode = mode
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def names = @map.keys
|
|
29
|
+
|
|
30
|
+
def empty? = @map.empty?
|
|
31
|
+
|
|
32
|
+
# Fresh values for one run. Callables are invoked now, never memoized.
|
|
33
|
+
def resolve
|
|
34
|
+
@map.transform_values { |v| (v.respond_to?(:call) ? v.call : v).to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Values (and the closures that mint them) never appear in logs.
|
|
38
|
+
def inspect = "#<Hermetic::Credentials mode=#{@mode} names=#{names.inspect} [redacted]>"
|
|
39
|
+
alias to_s inspect
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# A disabled (Null) sandbox was asked to run — fail loud, never no-op.
|
|
5
|
+
class DisabledError < Error; end
|
|
6
|
+
|
|
7
|
+
# Bad construction or a malformed run request: path traversal in files:,
|
|
8
|
+
# a non-integer timeout, an unknown credential mode, etc.
|
|
9
|
+
class ConfigError < Error; end
|
|
10
|
+
|
|
11
|
+
# The wire failed (socket error, missing binary) — distinct from the guest
|
|
12
|
+
# failing, which is a non-zero Result, not an exception. `in_doubt` is true
|
|
13
|
+
# when the run may have executed before the wire failed — the remote/hosted
|
|
14
|
+
# in-doubt window (spec §7 risk 2). Never blindly retry an in-doubt run:
|
|
15
|
+
# replay the same idempotency key against a deduping executor, or escalate.
|
|
16
|
+
class TransportError < Error
|
|
17
|
+
attr_reader :in_doubt
|
|
18
|
+
|
|
19
|
+
def initialize(message = nil, in_doubt: false)
|
|
20
|
+
super(message)
|
|
21
|
+
@in_doubt = in_doubt
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def in_doubt? = !!@in_doubt
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Host-side wall-clock enforcement failed in a way that can't be expressed
|
|
28
|
+
# as the normal exit-124 Result (e.g. the reaper couldn't kill the guest).
|
|
29
|
+
class TimeoutError < Error; end
|
|
30
|
+
|
|
31
|
+
# Workspace materialization failed — files: couldn't be placed in the
|
|
32
|
+
# guest's workdir.
|
|
33
|
+
class WorkspaceError < Error; end
|
|
34
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
# Resource ceilings for a run. Set on the box at construction, overridable
|
|
3
|
+
# per run via `limits:` (merged over the box defaults).
|
|
4
|
+
class Limits
|
|
5
|
+
DEFAULTS = { memory: "512m", cpus: "1", pids: 256 }.freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :memory, :cpus, :pids
|
|
8
|
+
|
|
9
|
+
def self.build(value)
|
|
10
|
+
case value
|
|
11
|
+
when nil then new
|
|
12
|
+
when Limits then value
|
|
13
|
+
when Hash then new.merge(value)
|
|
14
|
+
else raise ConfigError, "limits must be a Hash or Hermetic::Limits, got #{value.inspect}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(memory: DEFAULTS[:memory], cpus: DEFAULTS[:cpus], pids: DEFAULTS[:pids])
|
|
19
|
+
@memory = memory.to_s
|
|
20
|
+
@cpus = cpus.to_s
|
|
21
|
+
@pids = Integer(pids)
|
|
22
|
+
raise ConfigError, "pids must be positive, got #{pids.inspect}" unless @pids.positive?
|
|
23
|
+
rescue ArgumentError, TypeError
|
|
24
|
+
raise ConfigError, "pids must be an integer, got #{pids.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def merge(overrides)
|
|
28
|
+
return self if overrides.nil?
|
|
29
|
+
|
|
30
|
+
overrides = overrides.to_h.transform_keys(&:to_sym)
|
|
31
|
+
unknown = overrides.keys - DEFAULTS.keys
|
|
32
|
+
raise ConfigError, "unknown limit(s) #{unknown.inspect} — known: #{DEFAULTS.keys.inspect}" unless unknown.empty?
|
|
33
|
+
|
|
34
|
+
self.class.new(**to_h.merge(overrides))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def to_h = { memory: memory, cpus: cpus, pids: pids }
|
|
38
|
+
|
|
39
|
+
def ==(other) = other.is_a?(Limits) && other.to_h == to_h
|
|
40
|
+
alias eql? ==
|
|
41
|
+
|
|
42
|
+
def hash = to_h.hash
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
# Guest networking is default-deny (:none). Opting in means naming an
|
|
3
|
+
# egress allowlist — "host:port" entries — never "open". No inbound ever.
|
|
4
|
+
class NetworkPolicy
|
|
5
|
+
EGRESS_ENTRY = /\A[A-Za-z0-9*._-]+:\d{1,5}\z/
|
|
6
|
+
|
|
7
|
+
attr_reader :egress
|
|
8
|
+
|
|
9
|
+
def self.from(value)
|
|
10
|
+
case value
|
|
11
|
+
when NetworkPolicy then value
|
|
12
|
+
when :none, nil then NONE
|
|
13
|
+
else raise ConfigError, "network must be :none or a Hermetic::NetworkPolicy, got #{value.inspect}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(egress: [])
|
|
18
|
+
@egress = Array(egress).map(&:to_s).freeze
|
|
19
|
+
@egress.each do |entry|
|
|
20
|
+
raise ConfigError, %(egress entries are "host:port", got #{entry.inspect}) unless entry.match?(EGRESS_ENTRY)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def none? = egress.empty?
|
|
25
|
+
|
|
26
|
+
def open? = !none?
|
|
27
|
+
|
|
28
|
+
# The docker/gvisor `--network` value. Default-deny stays "none"; an
|
|
29
|
+
# egress allowlist needs a bridge — enforcing the allowlist itself is the
|
|
30
|
+
# backend's job, this flag only stops it being silently unreachable.
|
|
31
|
+
def docker_flag = none? ? "none" : "bridge"
|
|
32
|
+
|
|
33
|
+
# The firecracker "network-interfaces" machine config. Default-deny
|
|
34
|
+
# attaches no NIC at all — the guest has no device to speak through.
|
|
35
|
+
# An egress allowlist attaches one tap iface; enforcing the allowlist on
|
|
36
|
+
# that tap (nftables) is the orchestration layer's job — same split of
|
|
37
|
+
# responsibility as docker_flag.
|
|
38
|
+
def firecracker_ifaces(iface_id: "eth0", host_dev_name: "tap0", guest_mac: "06:00:AC:10:00:02")
|
|
39
|
+
return [] if none?
|
|
40
|
+
|
|
41
|
+
[ { "iface_id" => iface_id, "host_dev_name" => host_dev_name, "guest_mac" => guest_mac } ]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def to_s = none? ? "none" : "egress:#{egress.join(',')}"
|
|
45
|
+
|
|
46
|
+
def inspect = "#<Hermetic::NetworkPolicy #{self}>"
|
|
47
|
+
|
|
48
|
+
def ==(other) = other.is_a?(NetworkPolicy) && other.egress == egress
|
|
49
|
+
|
|
50
|
+
NONE = new.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
# Off-host executor CLIENT — the self-host trust boundary (spec §3). Wraps a
|
|
5
|
+
# local backend *kind* (:gvisor/:firecracker/:docker) and ships each run as
|
|
6
|
+
# JSON to a dedicated sandbox host that holds no database creds, no
|
|
7
|
+
# RAILS_MASTER_KEY, no ledger. This process keeps only the URL + token.
|
|
8
|
+
# EXECUTOR.md is the server side of this contract.
|
|
9
|
+
class Remote < Backends::Base
|
|
10
|
+
BACKENDS = %i[docker gvisor firecracker].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :executor, :backend
|
|
13
|
+
|
|
14
|
+
def initialize(executor:, token:, backend: :gvisor, backend_options: {}, transport: nil, **opts)
|
|
15
|
+
raise ConfigError, "executor URL is required" if executor.to_s.strip.empty?
|
|
16
|
+
raise ConfigError, "token is required — the executor only accepts authenticated runs" if token.to_s.strip.empty?
|
|
17
|
+
unless BACKENDS.include?(backend)
|
|
18
|
+
raise ConfigError, "unknown executor backend #{backend.inspect} (known: #{BACKENDS.inspect})"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
super(trust: :remote, **opts)
|
|
22
|
+
@executor = executor.to_s.chomp("/")
|
|
23
|
+
@token = token
|
|
24
|
+
@backend = backend
|
|
25
|
+
@backend_options = backend_options.to_h
|
|
26
|
+
@transport = transport || Transport::Http.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Result#backend reports the isolation tech the executor runs; trust
|
|
30
|
+
# :remote carries the boundary — the two axes stay unconflated (spec §3).
|
|
31
|
+
def backend_name = @backend
|
|
32
|
+
|
|
33
|
+
# Pure: one run as an HTTP request spec. Idempotency-Key is deterministic
|
|
34
|
+
# for the same logical run — the executor-side dedup handle that makes a
|
|
35
|
+
# retry of an in-doubt run safe (spec §7 risk 2, EXECUTOR.md).
|
|
36
|
+
def run_spec(request)
|
|
37
|
+
{
|
|
38
|
+
method: :post,
|
|
39
|
+
url: "#{@executor}/run",
|
|
40
|
+
headers: {
|
|
41
|
+
"Authorization" => "Bearer #{@token}",
|
|
42
|
+
"Content-Type" => "application/json",
|
|
43
|
+
"Idempotency-Key" => request.idempotency_key
|
|
44
|
+
},
|
|
45
|
+
body: JSON.generate(
|
|
46
|
+
v: 1,
|
|
47
|
+
backend: @backend,
|
|
48
|
+
backend_options: @backend_options,
|
|
49
|
+
argv: request.exec_argv,
|
|
50
|
+
stdin: request.stdin,
|
|
51
|
+
files: request.files,
|
|
52
|
+
env: request.clean_env,
|
|
53
|
+
network: { egress: request.network.egress },
|
|
54
|
+
timeout: request.timeout,
|
|
55
|
+
limits: request.limits.to_h
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# In-doubt discipline (v1: no automatic retry):
|
|
63
|
+
# wire failure -> the POST may have executed guest-side: in_doubt
|
|
64
|
+
# HTTP 4xx -> the executor refused before starting the run: plain failure
|
|
65
|
+
# HTTP 5xx -> the executor broke mid-flight: in_doubt
|
|
66
|
+
# A caller that retries replays the SAME logical run; the stable
|
|
67
|
+
# idempotency key lets a deduping executor collapse it.
|
|
68
|
+
def execute(request)
|
|
69
|
+
response = begin
|
|
70
|
+
@transport.call(run_spec(request))
|
|
71
|
+
rescue TransportError => e
|
|
72
|
+
raise TransportError.new(
|
|
73
|
+
"executor unreachable or response lost (in doubt, idempotency_key=#{request.idempotency_key}): #{e.message}",
|
|
74
|
+
in_doubt: true
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless response.success?
|
|
79
|
+
raise TransportError.new(
|
|
80
|
+
"executor returned HTTP #{response.status} (idempotency_key=#{request.idempotency_key}): " \
|
|
81
|
+
"#{response.body.to_s[0, 200]}",
|
|
82
|
+
in_doubt: response.status >= 500
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
payload = response.json
|
|
87
|
+
[ payload["stdout"].to_s, payload["stderr"].to_s,
|
|
88
|
+
payload["exit_status"].to_i, !!payload["timed_out"] ]
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raise TransportError.new(
|
|
91
|
+
"executor returned unparseable JSON (in doubt, idempotency_key=#{request.idempotency_key})",
|
|
92
|
+
in_doubt: true
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Hermetic
|
|
6
|
+
# The normalized form of one `run` call. Backends never see raw kwargs —
|
|
7
|
+
# they see a Request whose invariants already hold: shell-vs-argv resolved,
|
|
8
|
+
# workdir-relative files only, clean default-deny env, integer host-enforced
|
|
9
|
+
# timeout, limits merged over box defaults.
|
|
10
|
+
class Request
|
|
11
|
+
SHELL = [ "/bin/sh", "-c" ].freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :files, :stdin, :network, :timeout, :limits, :env_allowlist
|
|
14
|
+
|
|
15
|
+
def self.build(command, **kwargs) = new(command, **kwargs)
|
|
16
|
+
|
|
17
|
+
def initialize(command, files: {}, stdin: nil, env: {}, network: :none,
|
|
18
|
+
timeout: 30, limits: nil, default_limits: Limits.new,
|
|
19
|
+
credentials: nil, env_allowlist: [], host_env: ENV)
|
|
20
|
+
@command = normalize_command(command)
|
|
21
|
+
@files = normalize_files(files)
|
|
22
|
+
@stdin = stdin&.to_s
|
|
23
|
+
@explicit_env = normalize_env(env)
|
|
24
|
+
@network = NetworkPolicy.from(network)
|
|
25
|
+
@timeout = normalize_timeout(timeout)
|
|
26
|
+
@limits = default_limits.merge(limits)
|
|
27
|
+
@credentials = Credentials.build(credentials)
|
|
28
|
+
@env_allowlist = Array(env_allowlist).map(&:to_s).freeze
|
|
29
|
+
@host_env = host_env
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def shell? = @command.is_a?(String)
|
|
33
|
+
|
|
34
|
+
# Full argv to exec. A String command runs under /bin/sh -c (matches
|
|
35
|
+
# Silas); an Array execs directly — no shell, no injection surface.
|
|
36
|
+
def exec_argv = shell? ? SHELL + [ @command ] : @command
|
|
37
|
+
|
|
38
|
+
# The command as a single shell string, for hosted APIs that take one.
|
|
39
|
+
def exec_string = shell? ? @command : Shellwords.join(@command)
|
|
40
|
+
|
|
41
|
+
# The guest environment: host env is default-deny. Precedence (low→high):
|
|
42
|
+
# allowlisted host vars < resolved credentials < explicit env:.
|
|
43
|
+
# Credentials resolve lazily, per call — never cached on the request.
|
|
44
|
+
def clean_env
|
|
45
|
+
allowlisted = @env_allowlist.each_with_object({}) do |name, acc|
|
|
46
|
+
acc[name] = @host_env[name].to_s if @host_env[name]
|
|
47
|
+
end
|
|
48
|
+
allowlisted.merge(@credentials.resolve).merge(@explicit_env)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def credential_names = @credentials.names
|
|
52
|
+
|
|
53
|
+
# Deterministic across retries of the same logical run — the executor-side
|
|
54
|
+
# dedup key for the remote in-doubt window (spec §7 risk 2). Credential and
|
|
55
|
+
# host-env VALUES are excluded: short-lived tokens must not change the key
|
|
56
|
+
# (and must never be digested at all).
|
|
57
|
+
def idempotency_key
|
|
58
|
+
@idempotency_key ||= Digest::SHA256.hexdigest(JSON.generate(canonical))[0, 32]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Values are redacted; keys/command are the model-visible surface anyway.
|
|
62
|
+
def inspect
|
|
63
|
+
"#<Hermetic::Request cmd=#{exec_argv.inspect} files=#{files.keys.inspect} " \
|
|
64
|
+
"env=#{(@explicit_env.keys + credential_names).sort.inspect} " \
|
|
65
|
+
"network=#{@network} timeout=#{@timeout} [values redacted]>"
|
|
66
|
+
end
|
|
67
|
+
alias to_s inspect
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def canonical
|
|
72
|
+
{
|
|
73
|
+
v: 1,
|
|
74
|
+
shell: shell?,
|
|
75
|
+
argv: exec_argv,
|
|
76
|
+
stdin: @stdin,
|
|
77
|
+
files: @files.sort.to_h,
|
|
78
|
+
env: @explicit_env.sort.to_h,
|
|
79
|
+
env_allowlist: @env_allowlist.sort,
|
|
80
|
+
credential_names: credential_names.sort,
|
|
81
|
+
egress: @network.egress,
|
|
82
|
+
timeout: @timeout,
|
|
83
|
+
limits: @limits.to_h
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def normalize_command(command)
|
|
88
|
+
case command
|
|
89
|
+
when String
|
|
90
|
+
raise ConfigError, "command must not be empty" if command.strip.empty?
|
|
91
|
+
|
|
92
|
+
command
|
|
93
|
+
when Array
|
|
94
|
+
raise ConfigError, "argv must not be empty" if command.empty?
|
|
95
|
+
raise ConfigError, "argv must be an Array of Strings, got #{command.inspect}" unless command.all?(String)
|
|
96
|
+
|
|
97
|
+
command.dup.freeze
|
|
98
|
+
else
|
|
99
|
+
raise ConfigError, "command must be a String (/bin/sh -c) or an Array argv, got #{command.inspect}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def normalize_files(files)
|
|
104
|
+
files.to_h.each_with_object({}) do |(path, contents), acc|
|
|
105
|
+
path = path.to_s
|
|
106
|
+
if path.empty? || path.start_with?("/") || path.split("/").include?("..")
|
|
107
|
+
raise ConfigError, "unsafe file path #{path.inspect} — workdir-relative only, no traversal"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
acc[path] = contents.to_s
|
|
111
|
+
end.freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_env(env)
|
|
115
|
+
env.to_h.map { |k, v| [ k.to_s, v.to_s ] }.to_h.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def normalize_timeout(timeout)
|
|
119
|
+
seconds = Integer(timeout)
|
|
120
|
+
raise ConfigError, "timeout must be positive, got #{timeout.inspect}" unless seconds.positive?
|
|
121
|
+
|
|
122
|
+
seconds
|
|
123
|
+
rescue ArgumentError, TypeError
|
|
124
|
+
raise ConfigError, "timeout must be an integer number of seconds, got #{timeout.inspect}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
# Superset of Silas::Sandbox::Result — the first three members are the ones
|
|
3
|
+
# Silas's run_code tool reads, so a Hermetic box drops into
|
|
4
|
+
# `config.sandbox = <object>` with zero Silas changes. trust/backend make
|
|
5
|
+
# the isolation posture legible on every result (see spec §3).
|
|
6
|
+
Result = Struct.new(
|
|
7
|
+
:stdout, :stderr, :exit_status,
|
|
8
|
+
:timed_out, :duration_ms, :backend, :trust,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
) do
|
|
11
|
+
def success? = exit_status.to_i.zero? && !timed_out
|
|
12
|
+
|
|
13
|
+
def timed_out? = !!timed_out
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "hermetic"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
# OPTIONAL Silas shim — `require "hermetic/silas"` from an app that runs
|
|
5
|
+
# Silas (spec §4). A sandbox run is a non-replayable external effect, so it
|
|
6
|
+
# must never happen inside a ledger transaction: a rollback after the guest
|
|
7
|
+
# ran would replay it on retry. Silas's own Docker adapter carries this
|
|
8
|
+
# guard; prepending here gives every Hermetic backend the same fail-loud
|
|
9
|
+
# posture. Without Silas loaded the check no-ops, so requiring this file is
|
|
10
|
+
# always safe.
|
|
11
|
+
module SilasLedgerGuard
|
|
12
|
+
def run(...)
|
|
13
|
+
if defined?(::Silas::Ledger) && ::Silas::Ledger.in_transaction?
|
|
14
|
+
raise Error, "sandbox exec inside a ledger transaction — sandbox-backed tools " \
|
|
15
|
+
"must be at_most_once!, never transactional!"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Backends::Base.prepend(SilasLedgerGuard)
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hermetic
|
|
6
|
+
module Transport
|
|
7
|
+
# Sends a backend-built request spec ({ method:, url:, headers:, body: })
|
|
8
|
+
# over net/http. The connection factory is injectable so unit tests never
|
|
9
|
+
# open a socket — fakes return canned responses or raise socket errors.
|
|
10
|
+
class Http
|
|
11
|
+
REQUESTS = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.freeze
|
|
12
|
+
|
|
13
|
+
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
14
|
+
def json = JSON.parse(body)
|
|
15
|
+
|
|
16
|
+
def success? = (200..299).cover?(status)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(open_timeout: 5, read_timeout: 65, connector: nil)
|
|
20
|
+
@open_timeout = open_timeout
|
|
21
|
+
@read_timeout = read_timeout
|
|
22
|
+
@connector = connector || method(:default_connector)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(spec)
|
|
26
|
+
method = spec.fetch(:method) { raise ConfigError, "http spec needs :method" }.to_s.upcase
|
|
27
|
+
uri = URI(spec.fetch(:url) { raise ConfigError, "http spec needs :url" })
|
|
28
|
+
klass = REQUESTS.fetch(method) { raise ConfigError, "unsupported http method #{method.inspect}" }
|
|
29
|
+
|
|
30
|
+
request = klass.new(uri)
|
|
31
|
+
(spec[:headers] || {}).each { |name, value| request[name.to_s] = value.to_s }
|
|
32
|
+
request.body = spec[:body] if spec[:body]
|
|
33
|
+
|
|
34
|
+
raw = @connector.call(uri).request(request)
|
|
35
|
+
Response.new(status: raw.code.to_i, body: raw.body.to_s)
|
|
36
|
+
rescue SocketError, SystemCallError, IOError, EOFError,
|
|
37
|
+
Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
38
|
+
raise TransportError, "#{method} #{uri}: #{e.class}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def default_connector(uri)
|
|
44
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
45
|
+
http.use_ssl = uri.scheme == "https"
|
|
46
|
+
http.open_timeout = @open_timeout
|
|
47
|
+
http.read_timeout = @read_timeout
|
|
48
|
+
http
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|