terret-sandbox-docker 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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/terret/sandbox/docker.rb +408 -0
  3. metadata +64 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7fcb304146e127a21be986fd9982cb3024cb8ac410c86f9462b97fabf1a8a448
4
+ data.tar.gz: 624bba84f98483ae4e5968affc41070de430a71b5ac16733c420bef9dca388d0
5
+ SHA512:
6
+ metadata.gz: 859852ea781a7ad25a0c4091561f2c005f2395f60c7232e42b081bd5808f8bacf089272f88e922ffb92d88c0a97957a21c272e68ba13b7126ef4316f13868543
7
+ data.tar.gz: 3e6be925fcc6f489a5c18930090646ec1c1a2d1469256ac296b12a99261ff816fa16180302a84067f7276cb40d89aed62559c00670252e9a1880dfa98accc6c1
@@ -0,0 +1,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "terret"
5
+ rescue LoadError
6
+ require_relative "../../../../terret-core/lib/terret" # monorepo path source
7
+ end
8
+
9
+ module Terret
10
+ module Sandbox
11
+ # There is no container to run anything in: the daemon is not there, the
12
+ # image cannot be had, or `docker run` refused. Raised rather than
13
+ # returned, because every other outcome on this seam describes a command
14
+ # that actually ran somewhere.
15
+ ContainerUnavailable = Class.new(Terret::Tools::Failure)
16
+
17
+ # A cwd outside the granted workspace. The name deliberately echoes
18
+ # ctx[:fs]'s Denied, and for the same reason: the workspace is the only
19
+ # thing that exists inside the container, so a path outside it is refused
20
+ # rather than quietly relocated to somewhere the caller did not ask for.
21
+ Denied = Class.new(Terret::Tools::Failure)
22
+
23
+ # ctx[:sandbox] — the container provider (plan §12). This is the row that
24
+ # makes the M7 claim true: swap `SandboxNone` for this plugin in one patch
25
+ # row and Bash, Read, Write, Grep and the PTY tools all start running
26
+ # inside a container, with no change to any tool. Nothing here knows what
27
+ # a tool is; it only turns an argv into a `docker exec` argv, and the
28
+ # ctx[:subprocess] seam does the rest.
29
+ #
30
+ # ONE WORLD. Each granted workspace directory is bind-mounted at its own
31
+ # absolute path, so `/ws/a.rb` on the host is `/ws/a.rb` in the container.
32
+ # That is what lets the file tools stay on the host (ctx[:fs] writes
33
+ # through the mount) while the process tools run inside, without either
34
+ # side translating paths. The mounted path is the REALPATH, resolved
35
+ # exactly the way ctx[:fs] resolves its own roots — on macOS a tmpdir is
36
+ # handed out under /var and /var is a symlink to /private/var, so a
37
+ # provider that mounted the un-resolved name would disagree with every
38
+ # path fs produces.
39
+ #
40
+ # IT SHELLS OUT TO DOCKER DIRECTLY, with plain Process.spawn/IO.popen,
41
+ # never through ctx[:subprocess]. That is not an oversight: this service
42
+ # sits BENEATH the seam subprocess consults, so routing its own `docker
43
+ # run` through subprocess would send it back through #wrap and try to
44
+ # start the container inside the container it is starting.
45
+ #
46
+ # WHAT IT DOES NOT ISOLATE, stated rather than implied. The workspace is
47
+ # shared read-write with the host by design, so a command in the container
48
+ # can still rewrite anything ctx[:fs] could. The isolation this buys is the
49
+ # rest of the host — the filesystem outside the workspace, the process
50
+ # table, and (at `network: "none"`) the network.
51
+ #
52
+ # CANCELLATION DOES NOT CROSS THE BOUNDARY, and this one is a real loss
53
+ # rather than a footnote. Every kill in the harness — #spawn's timeout
54
+ # escalation, a terminal's close, ctx[:shell]'s sweep — signals a pid on
55
+ # the HOST, and under this provider that pid is the `docker exec` CLI, not
56
+ # the process it started. Killing the CLI detaches; the command inside the
57
+ # container keeps running. So a timed-out command is reported as timed out
58
+ # and truthfully was abandoned, but it goes on burning the container's CPU
59
+ # until something stops the container itself, and ctx[:shell]'s `set +m`
60
+ # guarantee — that one signal to the session's process group ends every
61
+ # child it started — is void here, because that process group lives in
62
+ # another pid namespace. Ending the container (#stop, or #restart!) is the
63
+ # only cancellation that reaches inside it. Closing this properly means
64
+ # `docker exec` growing a way to signal what it started, or the provider
65
+ # tracking in-container pids itself; neither belongs in M7.
66
+ #
67
+ # ENV DOES NOT CROSS. `Subprocess#spawn(env:)` applies to the docker CLI
68
+ # process on the host, not to the process inside the container: variables
69
+ # set that way configure `docker`, and the command never sees them. In-
70
+ # container environment comes from the image and from what the command
71
+ # itself exports (ctx[:shell]'s session keeps `export`s across calls, which
72
+ # is the ordinary way an agent sets one). Propagating selected variables
73
+ # with `docker exec -e` is a plausible M8 knob and is deliberately not
74
+ # built here — a sandbox that silently forwarded the host's environment
75
+ # would be a hole, not a feature.
76
+ class Docker < Hames::Service
77
+ service_key :sandbox
78
+ config_schema image: { type: String, default: "ruby:slim", doc: "container image argv runs in" },
79
+ network: { type: String, default: "none",
80
+ doc: "docker --network mode (none, bridge, host, or a network name)" },
81
+ workspace: { type: [String, Array],
82
+ doc: "host directory root(s) mounted into the container" },
83
+ user: { type: String,
84
+ doc: "container user (default: the host uid:gid); nil runs as root" },
85
+ docker_bin: { type: String, doc: "path to the docker binary (default: resolved from PATH)" },
86
+ memory: { type: String,
87
+ doc: "docker --memory limit (e.g. 256m, 1g); unset means no limit" },
88
+ cpus: { type: [String, Numeric],
89
+ doc: "docker --cpus limit (e.g. 1.5); unset means no limit" },
90
+ pids: { type: Integer,
91
+ doc: "docker --pids-limit, the container's max process count; unset means no limit" }
92
+
93
+ # Debian-based, and chosen for what it carries rather than for Ruby:
94
+ # ctx[:shell] spawns `bash` (the sentinel protocol is a bash protocol),
95
+ # so an image without bash breaks the Bash tool the moment this row is
96
+ # mounted. It also carries getent, which is how the network-denial test
97
+ # asks a question that fails fast instead of timing out.
98
+ DEFAULT_IMAGE = "ruby:slim"
99
+
100
+ # Denied by default. A profile that wants the agent on the network says
101
+ # so in the row; it is not something an isolation provider grants
102
+ # quietly.
103
+ DEFAULT_NETWORK = "none"
104
+
105
+ # Every container started here carries this label, so one that outlives
106
+ # its process — a crashed harness, a killed test run — is identifiable
107
+ # with `docker ps --filter label=terret-sandbox` rather than by guessing
108
+ # which `sleep infinity` was ours.
109
+ LABEL = "terret-sandbox"
110
+
111
+ # nil until the container exists. Public because an operator (and the
112
+ # suite) needs to be able to ask which container an agent is living in
113
+ # without having to run a command to find out.
114
+ attr_reader :container
115
+
116
+ def start(_ctx)
117
+ @workspace = resolve_workspace(config[:workspace])
118
+ @container = nil
119
+ @lock = Mutex.new
120
+ @docker_bin = resolve_docker_bin(config[:docker_bin])
121
+ end
122
+
123
+ def isolated? = true
124
+
125
+ # The absolute path every docker invocation here runs through. Public so
126
+ # a test (and an operator) can see which binary the row resolved to.
127
+ def docker_bin = @docker_bin
128
+
129
+ # The wrapped argv. `workspace_ready!` is called from HERE, not by
130
+ # ctx[:subprocess], because the seam's contract is only `wrap` — the
131
+ # caller hands over an argv and gets back one that runs somewhere else,
132
+ # and whether that somewhere had to be created first is this service's
133
+ # business. It also means the container is started lazily: mounting the
134
+ # row costs nothing until an agent actually runs something.
135
+ #
136
+ # `-i` is always present. Without it `docker exec` does not attach stdin
137
+ # at all (measured: a wrapped `cat` fed a payload returns nothing, and a
138
+ # wrapped bash gets EOF before it can answer its handshake), which would
139
+ # break both ctx[:shell]'s protocol and every #spawn that writes stdin.
140
+ #
141
+ # `-t` is added only when the caller asks, and the asymmetry is forced by
142
+ # docker rather than chosen: `docker exec -i -t` REFUSES to run when the
143
+ # CLI's own stdin is a pipe ("cannot attach stdin to a TTY-enabled
144
+ # container because stdin is not a terminal", exit 1), so a `-t` on
145
+ # every call would fail every ctx[:subprocess]#spawn. The flag is a
146
+ # per-call fact — this argv is going to a pty — and only the caller
147
+ # knows it.
148
+ #
149
+ # It matters more than a cosmetic terminal. Over a pty WITHOUT `-t`, the
150
+ # container gives bash a pipe: bash goes non-interactive and the
151
+ # handshake's `stty -echo` has no terminal to quiet, while the host pty
152
+ # PTY.spawn created is still echoing every byte written to it. The
153
+ # request line comes back inside the command's output — and that line
154
+ # contains the session sentinel, the one value ctx[:shell]'s forgery
155
+ # resistance rests on. With `-t` the docker CLI puts the host pty in raw
156
+ # mode and gives the container a real terminal, `stty -echo` lands, and
157
+ # stdout is exactly the command's output as the seam promises.
158
+ #
159
+ # The limitation that leaves: a caller who reaches a pty through a path
160
+ # that cannot pass `tty: true` gets a working but echoing session. Since
161
+ # ctx[:subprocess]#pty_spawn is the only way to a pty in the harness,
162
+ # that is the one call site that has to say so.
163
+ def wrap(argv, cwd:, tty: false)
164
+ workspace_ready!
165
+ [docker_bin, "exec", "-i", *(tty ? ["-t"] : []), "-w", workdir(cwd), @container, *argv]
166
+ end
167
+
168
+ # Idempotent, and the lock is what makes that true rather than nearly
169
+ # true. A bare `@container ||= run_container!` reads, PARKS (the `docker
170
+ # run` capture is blocking IO, which under a fiber scheduler yields), and
171
+ # only then assigns — so N agents reaching their first wrap together each
172
+ # see nil, each start a container, and only the last to assign is
173
+ # remembered. The others are orphans in the worst sense: never stopped,
174
+ # `--rm` never fires because `sleep infinity` never exits, and nothing
175
+ # left in the process knows their ids. That is the ordinary case, not a
176
+ # pathological one, because ctx[:subprocess] parks the fiber rather than
177
+ # the thread precisely so one reactor can serve many agents at once.
178
+ #
179
+ # Mutex is fiber-aware under a scheduler, so a waiter parks instead of
180
+ # blocking the reactor, and it is stdlib — this gem still has no runtime
181
+ # dependency. The `docker run` is held INSIDE the lock deliberately: the
182
+ # window being closed is exactly the one that spans it.
183
+ #
184
+ # There is still deliberately NO liveness check here — it would cost a
185
+ # docker round-trip on every single wrap, which is every spawn in the
186
+ # harness. A container that died underneath us surfaces as the exec's own
187
+ # failure; #restart! is how a caller recovers from it.
188
+ def workspace_ready!
189
+ @lock.synchronize { @container ||= run_container! }
190
+ end
191
+
192
+ # Recovery for a container that left without us: an operator's `docker
193
+ # rm`, an OOM kill, a Docker daemon restart. Nothing in this gem can
194
+ # notice that on its own — the provider only builds an argv, and the exec
195
+ # that would have reported "No such container" is run by
196
+ # ctx[:subprocess], which has no path back to the seam to say so. So
197
+ # recovery is explicit: drop the id being held, remove the container if
198
+ # it somehow is still there, and let the next #wrap build a fresh one.
199
+ # Without it a dead container stays dead for the life of the process and
200
+ # only a remount brings the agent back.
201
+ #
202
+ # What a restart costs is the same either way: everything the old
203
+ # container held is gone with it — ctx[:shell]'s sessions, open
204
+ # terminals, anything a command exported. A fresh one starts as fresh as
205
+ # the first one did.
206
+ def restart! = discard!
207
+
208
+ # The loader's unload hook, and the only thing standing between a
209
+ # crashed agent and a `sleep infinity` holding a bind mount forever. The
210
+ # default argument is what lets a caller that is not the loader — a test,
211
+ # an operator — end the container without inventing a context.
212
+ def stop(_ctx = nil) = discard!
213
+
214
+ # A live image, network or workspace swap is a remount, and saying so is
215
+ # more honest than pretending. The container is already running on the
216
+ # old image with the old mounts; applying a new row would mean replacing
217
+ # it, which would drop every ctx[:shell] session and every open terminal
218
+ # living inside it — a good deal more than a config change should do
219
+ # without the mounting profile asking for it.
220
+ def reconfigure(_config)
221
+ warn "terret-sandbox-docker: the container is already running on the mounted image, " \
222
+ "network and bind mounts; remount the row to apply a new one"
223
+ end
224
+
225
+ private
226
+
227
+ # Under the same lock as #workspace_ready!, so a disposal that lands
228
+ # while another fiber is mid-`docker run` cannot clear an id that is
229
+ # about to be assigned and leave the new container orphaned — the mirror
230
+ # image of the race the lock is there to close.
231
+ #
232
+ # Tolerant of a container that is already gone, which is the ordinary
233
+ # case for #restart!: `--rm` means the daemon may have removed it first,
234
+ # and a disposal path that raised on an already-clean state would turn
235
+ # tidy-up into a second failure.
236
+ def discard!
237
+ @lock.synchronize do
238
+ id = @container or next
239
+ # Removal is attempted FIRST, and the id is cleared only once it is
240
+ # gone: a genuine `docker rm -f` failure means `sleep infinity` may
241
+ # still be up holding its bind mount, so dropping the id here would
242
+ # strand a container nothing can name. A daemon that already removed
243
+ # the container (the ordinary `--rm` case) answers "No such
244
+ # container", which is success for our purposes, not a failure.
245
+ status, out = remove_container(id)
246
+ if status&.zero? || already_gone?(out)
247
+ @container = nil
248
+ else
249
+ warn "terret-sandbox-docker: docker rm -f #{id} failed (status #{status.inspect}); " \
250
+ "the container may still be running: #{out.strip}"
251
+ end
252
+ end
253
+ nil
254
+ end
255
+
256
+ # Split out so #discard! can distinguish a removal that FAILED from one
257
+ # that found the container already gone, and so a test can drive the
258
+ # failure path without a daemon. Both streams are merged the way #capture
259
+ # does, because the message is what makes a failed removal visible.
260
+ def remove_container(id)
261
+ out = IO.popen([docker_bin, "rm", "-f", id], err: [:child, :out], &:read)
262
+ [$?&.exitstatus, out.to_s]
263
+ end
264
+
265
+ def already_gone?(out) = out.to_s.match?(/no such container/i)
266
+
267
+ # Resolved to an ABSOLUTE path once, at start, so the bare name never
268
+ # reaches an exec that would resolve it against PATH — a workspace
269
+ # directory sitting on PATH could otherwise shadow `docker` with an
270
+ # argv[0] the agent controls. An explicit `docker_bin:` row wins
271
+ # (expanded to absolute); otherwise the first executable `docker` on PATH
272
+ # is taken, and if none is found the bare name is kept so a missing docker
273
+ # fails loudly rather than resolving somewhere unexpected.
274
+ def resolve_docker_bin(configured)
275
+ return File.expand_path(configured.to_s) if configured && !configured.to_s.empty?
276
+
277
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).filter_map do |dir|
278
+ next if dir.empty?
279
+
280
+ candidate = File.join(dir, "docker")
281
+ candidate if File.file?(candidate) && File.executable?(candidate)
282
+ end.first || "docker"
283
+ end
284
+
285
+ def image = config[:image] || DEFAULT_IMAGE
286
+ def network = config[:network] || DEFAULT_NETWORK
287
+
288
+ # Defaults to the HOST's uid:gid, and on Linux that default is
289
+ # load-bearing rather than a nicety. The workspace is bind-mounted
290
+ # read-write, so a container running as root writes root-owned files into
291
+ # it — and ctx[:fs], which runs as the host user, then cannot Write or
292
+ # Edit what the container just created. One world stops being one world.
293
+ # macOS hides this (Docker Desktop remaps ownership on the mount), which
294
+ # is exactly what makes it worth defaulting: silently fine on a
295
+ # developer's laptop, silently broken in Linux CI, which is where the
296
+ # acceptance run and the soak actually happen.
297
+ #
298
+ # The cost, measured rather than guessed: the host uid has no /etc/passwd
299
+ # entry inside the image, so `whoami` fails with "cannot find name for
300
+ # user ID 501", $HOME is `/`, and an interactive bash greets as "I have
301
+ # no name!". Harmless under `--norc --noprofile`, and the sentinel
302
+ # protocol is unaffected — the suite proves that rather than assuming it.
303
+ # A profile that needs root inside the container — to apt-get, or to
304
+ # install into the image — sets `user: nil` explicitly and takes the
305
+ # ownership problem back with it.
306
+ def user = config.fetch(:user) { "#{Process.uid}:#{Process.gid}" }
307
+
308
+ # `sleep infinity` rather than the image's own entrypoint: this container
309
+ # is a place to exec into, not a service, so it has to stay up and do
310
+ # nothing. `--rm` so a container whose process ends is not left behind as
311
+ # a stopped row for someone to sweep by hand.
312
+ #
313
+ # This blocks the calling thread, and the cost is worth stating: on the
314
+ # first wrap of a machine that does not have the image yet, that is a
315
+ # pull — minutes, during which the reactor is not running anyone else's
316
+ # agent. Pre-pulling the image is the answer; making this seam async is
317
+ # not, because everything downstream of it already assumes the container
318
+ # exists before an argv is wrapped.
319
+ def run_container!
320
+ if @workspace.empty?
321
+ raise ContainerUnavailable,
322
+ "no workspace directories were granted, so the container would have nothing " \
323
+ "mounted and every wrapped path would be missing inside it"
324
+ end
325
+
326
+ argv = [docker_bin, "run", "-d", "--rm", "--label", LABEL, "--network", network,
327
+ *resource_limits, *(user ? ["--user", user] : []), *mounts, image, "sleep", "infinity"]
328
+ status, out = capture(argv)
329
+ raise ContainerUnavailable, "docker run failed (status #{status.inspect}): #{out}" unless status&.zero?
330
+
331
+ container_id(out) or
332
+ raise ContainerUnavailable, "docker run reported success but printed no container id: #{out}"
333
+ end
334
+
335
+ # A workspace path containing a colon would confuse `-v`'s own
336
+ # source:target:options syntax. Left as a known limitation rather than
337
+ # worked around, because the alternative (`--mount`) trades a colon
338
+ # problem for a comma problem.
339
+ def mounts = @workspace.flat_map { |dir| ["-v", "#{dir}:#{dir}"] }
340
+
341
+ # Optional caps on what a container may consume, each mapping to the docker
342
+ # run flag of the same intent and each absent by default — an unset key
343
+ # adds no flag, so the container runs unconstrained unless a profile asks
344
+ # otherwise. This bounds the blast radius a `sandbox: none` profile has
345
+ # none of (docs/security.md): a wedged or hostile process in the container
346
+ # is held to the memory, CPU and pid budget the row granted it.
347
+ def resource_limits
348
+ flags = []
349
+ flags.push("--memory", config[:memory].to_s) if config[:memory]
350
+ flags.push("--cpus", config[:cpus].to_s) if config[:cpus]
351
+ flags.push("--pids-limit", config[:pids].to_s) if config[:pids]
352
+ flags
353
+ end
354
+
355
+ # `docker run -d` prints the id on stdout, but a run that had to pull
356
+ # first prints progress too, and this capture merges the streams. Picking
357
+ # the last full-length id out of the output is what survives that
358
+ # interleaving; matching the shape is what keeps a progress line from
359
+ # being mistaken for an id.
360
+ def container_id(out) = out.lines.map(&:strip).reverse.find { |line| line.match?(/\A\h{64}\z/) }
361
+
362
+ # Where the wrapped command runs. A nil cwd means the caller had no
363
+ # opinion (ctx[:subprocess] defaults it to the host's Dir.pwd, which is
364
+ # an opinion nobody formed) and gets the workspace root.
365
+ #
366
+ # Anything outside the workspace is refused, because it does not exist in
367
+ # the container: `docker exec -w` on an unmounted path fails with the OCI
368
+ # runtime's "chdir to cwd ... no such file or directory" as exit 127,
369
+ # which tells the reader nothing about workspaces. Failing here names the
370
+ # actual problem, and a profile that mounted this row without pointing
371
+ # ctx[:shell] at the workspace learns it from the first command instead
372
+ # of from a stack of 127s.
373
+ def workdir(cwd)
374
+ return @workspace.first if cwd.nil?
375
+
376
+ resolved = File.exist?(cwd) ? File.realpath(cwd) : File.expand_path(cwd)
377
+ unless contained?(resolved)
378
+ raise Denied, "#{cwd} is outside the granted workspace, so it does not exist in the container"
379
+ end
380
+
381
+ resolved
382
+ end
383
+
384
+ # The same trailing-separator guard ctx[:fs] uses: a workspace granted at
385
+ # `/ws` admits `/ws` and anything under `/ws/`, never `/ws-evil`.
386
+ def contained?(path)
387
+ @workspace.any? { |root| path == root || path.start_with?("#{root}/") }
388
+ end
389
+
390
+ # Resolved the way ctx[:fs] resolves its roots, and for the same reason:
391
+ # the two services are handed the SAME `workspace:` list by the profile,
392
+ # and if they disagreed about what those directories are named, every
393
+ # path fs produced would be a path the container does not have.
394
+ def resolve_workspace(dirs)
395
+ Array(dirs).map { |d| File.realpath(File.expand_path(d)) }
396
+ end
397
+
398
+ # Both streams, one process, no reader threads. Open3 would spawn a
399
+ # thread per stream for what is a short, blocking, once-per-container
400
+ # call, and its output would still have to be merged to be useful in an
401
+ # exception message.
402
+ def capture(argv)
403
+ out = IO.popen(argv, err: [:child, :out], &:read)
404
+ [$?&.exitstatus, out.to_s]
405
+ end
406
+ end
407
+ end
408
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terret-sandbox-docker
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
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: terret-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: 'ctx[:sandbox] backed by a long-lived container: one patch row swaps
27
+ this plugin in and every argv the harness spawns — Bash, Grep, the PTY tools — starts
28
+ running inside it, with no change to any tool. The workspace is bind-mounted at
29
+ its own realpath, so host-side file ops and in-container processes see one world
30
+ at one set of paths; the network is denied by default. Shells out to the docker
31
+ CLI, so there are no runtime dependencies beyond stdlib.'
32
+ email:
33
+ - obiefernandez@gmail.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - lib/terret/sandbox/docker.rb
39
+ homepage: https://terret.org
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ homepage_uri: https://terret.org
44
+ source_code_uri: https://github.com/terret-org/terret
45
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '4.0'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.16
62
+ specification_version: 4
63
+ summary: The container sandbox provider for Terret
64
+ test_files: []