Class: Nexo::Sandboxes::Container
- Inherits:
-
Nexo::Sandbox
- Object
- Nexo::Sandbox
- Nexo::Sandboxes::Container
- Defined in:
- lib/nexo/sandboxes/container.rb
Overview
Runs an agent's tools inside a throwaway OCI container via the docker
(default) or Apple container CLI — shell-out only, no client gem, no
Compose, no image builder. Implements the four-method sandbox contract
(+read+/+write+/+shell+/+glob+) plus close by shelling the runtime binary
through Open3, so a model-driven agent never touches the host filesystem
or shell directly.
Hardened by default, every knob an explicit opt-out:
--network none(no egress) — loosen withnetwork:.--cap-drop ALL— restore individual caps withcap_add:.--read-onlyrootfs + an ephemeral--tmpfs <cwd>:rwwritable scratch — disable withreadonly_rootfs: false.--security-opt no-new-privileges(always on).--pids-limit 512fork-bomb guard — override/omit withpids_limit:.- Host binds mounted read-only by default; a bind is writable only when the developer says so per-bind (+{ to:, mode: :rw }+).
Non-root is NOT forced — the image's own uid is respected; user: is an
opt-in defense-in-depth. The hardening above applies regardless of uid.
The container starts lazily on first tool use and its id is memoized.
Ephemeral by default: close force-removes the container. With name: +
reconnect: true the container is reused across sandboxes and close leaves
it in place.
Argv construction is pure (+run_argv+ and the exec argv builders) so the
offline suite asserts the exact Open3 argv with no daemon. Live runs are
+NEXO_LIVE+-gated smoke, never a core-suite dependency.
Constant Summary collapse
- RUNTIMES =
Maps the
runtime:option onto the host CLI binary. Frozen — the only two supported local runtimes in v1. {docker: "docker", apple: "container"}.freeze
Instance Attribute Summary collapse
-
#cwd ⇒ Object
readonly
The container working directory (default
/workspace, a container path), the selectedruntime(+:docker+ /:apple), and the requiredimage. -
#image ⇒ Object
readonly
The container working directory (default
/workspace, a container path), the selectedruntime(+:docker+ /:apple), and the requiredimage. -
#runtime ⇒ Object
readonly
The container working directory (default
/workspace, a container path), the selectedruntime(+:docker+ /:apple), and the requiredimage.
Instance Method Summary collapse
-
#close ⇒ Object
Force-removes the container (ephemeral default) and clears the memo.
-
#glob(pattern) ⇒ Object
Returns the container paths matching the glob
pattern(guarded). -
#initialize(image: nil, runtime: :docker, cwd: "/workspace", binds: {}, network: :none, cap_add: [], memory: nil, cpus: nil, pids_limit: 512, user: nil, env: {}, name: nil, reconnect: false, readonly_rootfs: true) ⇒ Container
constructor
image:is required (no default image). -
#instructions ⇒ Object
A short, human-readable description of the execution environment for the agent to inject later (consumed by the Refinements spec; until then the method simply exists and is correct).
-
#mtime(path) ⇒ Object
The last-modified time of
path(guarded) inside the container, read by shellingstat -c %Y(GNU coreutils epoch seconds). -
#read(path) ⇒ Object
Returns the contents of
path(guarded against escapingcwd) as a String. -
#shell(command, timeout: 30) ⇒ Object
Runs
commandinside the container and returns{ stdout:, stderr:, status: }(status is the integer exit code). -
#supports?(cap) ⇒ Boolean
truefor the four sandbox capabilities. -
#write(path, content) ⇒ Object
Writes
contenttopath(guarded) inside the container.
Constructor Details
#initialize(image: nil, runtime: :docker, cwd: "/workspace", binds: {}, network: :none, cap_add: [], memory: nil, cpus: nil, pids_limit: 512, user: nil, env: {}, name: nil, reconnect: false, readonly_rootfs: true) ⇒ Container
image: is required (no default image). runtime: selects the binary.
Every other keyword loosens one hardening default; see the class docs and
the README hardened-defaults table.
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/nexo/sandboxes/container.rb', line 49 def initialize(image: nil, runtime: :docker, cwd: "/workspace", binds: {}, network: :none, cap_add: [], memory: nil, cpus: nil, pids_limit: 512, user: nil, env: {}, name: nil, reconnect: false, readonly_rootfs: true) raise ConfigurationError, "container sandbox requires image:" if image.nil? @bin = RUNTIMES.fetch(runtime) do raise ConfigurationError, "unknown container runtime: #{runtime.inspect}" end @runtime = runtime @image = image @cwd = cwd @binds = binds @network = network @cap_add = cap_add @memory = memory @cpus = cpus @pids_limit = pids_limit @user = user @env = env @name = name || "nexo-#{Nexo.generate_run_id}" @reconnect = reconnect @readonly_rootfs = readonly_rootfs @cid = nil end |
Instance Attribute Details
#cwd ⇒ Object (readonly)
The container working directory (default /workspace, a container path),
the selected runtime (+:docker+ / :apple), and the required image.
44 45 46 |
# File 'lib/nexo/sandboxes/container.rb', line 44 def cwd @cwd end |
#image ⇒ Object (readonly)
The container working directory (default /workspace, a container path),
the selected runtime (+:docker+ / :apple), and the required image.
44 45 46 |
# File 'lib/nexo/sandboxes/container.rb', line 44 def image @image end |
#runtime ⇒ Object (readonly)
The container working directory (default /workspace, a container path),
the selected runtime (+:docker+ / :apple), and the required image.
44 45 46 |
# File 'lib/nexo/sandboxes/container.rb', line 44 def runtime @runtime end |
Instance Method Details
#close ⇒ Object
Force-removes the container (ephemeral default) and clears the memo.
Idempotent: safe with nothing started or called more than once. With
reconnect: true the container is left in place for a later sandbox to
reattach by its exact identity label. Teardown stays id-based
(+
116 117 118 119 120 121 |
# File 'lib/nexo/sandboxes/container.rb', line 116 def close if @cid && !@reconnect system(@bin, "rm", "-f", @cid, out: File::NULL, err: File::NULL) end @cid = nil end |
#glob(pattern) ⇒ Object
Returns the container paths matching the glob pattern (guarded). Empty
output yields [].
The guarded pattern is passed as a positional parameter (+$1+), NEVER
interpolated into the script text, so shell metacharacters in a
model-supplied pattern (+;+, $(), backticks) are inert data — +for f in
$1+ still performs pathname (glob) expansion, but nothing in $1 is ever
parsed as a command.
98 99 100 101 102 |
# File 'lib/nexo/sandboxes/container.rb', line 98 def glob(pattern) script = 'for f in $1; do [ -e "$f" ] && echo "$f"; done' out = exec!("sh", "-c", script, "sh", guard_path(pattern)) out[:stdout].split("\n") end |
#instructions ⇒ Object
A short, human-readable description of the execution environment for the agent to inject later (consumed by the Refinements spec; until then the method simply exists and is correct).
126 127 128 129 |
# File 'lib/nexo/sandboxes/container.rb', line 126 def instructions "You run inside a #{@runtime} container (image #{@image}), cwd #{@cwd}, " \ "network #{@network}. Only #{@cwd} and any :rw binds are writable." end |
#mtime(path) ⇒ Object
The last-modified time of path (guarded) inside the container, read by
shelling stat -c %Y (GNU coreutils epoch seconds). Returns a Time, or
nil when the file is absent — so a new-file write skips the
read-before-write guard. Used only by the R4 clobber guard.
141 142 143 144 145 146 147 148 149 |
# File 'lib/nexo/sandboxes/container.rb', line 141 def mtime(path) out = exec!("stat", "-c", "%Y", "--", guard_path(path)) return nil unless out[:status].zero? epoch = out[:stdout].strip epoch.empty? ? nil : Time.at(Integer(epoch)) rescue ArgumentError nil end |
#read(path) ⇒ Object
Returns the contents of path (guarded against escaping cwd) as a
String. Raises Errno::ENOENT when the file is absent, matching the other
sandboxes' read contract.
77 78 79 80 81 82 |
# File 'lib/nexo/sandboxes/container.rb', line 77 def read(path) out = exec!("cat", "--", guard_path(path)) raise Errno::ENOENT, path unless out[:status].zero? out[:stdout] end |
#shell(command, timeout: 30) ⇒ Object
Runs command inside the container and returns
{ stdout:, stderr:, status: } (status is the integer exit code). The
wall-clock bound lives in Timeout.timeout around Open3.capture3.
107 108 109 |
# File 'lib/nexo/sandboxes/container.rb', line 107 def shell(command, timeout: 30) exec!("sh", "-c", command, timeout: timeout) end |
#supports?(cap) ⇒ Boolean
true for the four sandbox capabilities. Unlike Virtual, :shell is
supported here (a real process runs the command).
133 134 135 |
# File 'lib/nexo/sandboxes/container.rb', line 133 def supports?(cap) %i[read write shell glob].include?(cap) end |
#write(path, content) ⇒ Object
Writes content to path (guarded) inside the container. Content travels
on stdin, never interpolated into the argv, so arbitrary bytes are safe.
86 87 88 |
# File 'lib/nexo/sandboxes/container.rb', line 86 def write(path, content) exec_stdin!(content, "sh", "-c", 'cat > "$0"', guard_path(path)) end |