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
- CAPABILITIES =
What each runtime's CLI can actually express. Verified live against
container1.2.2 on macOS and Docker 29.4: Apple's CLI rejects--security-optand--pids-limitwith "Unknown option", which abortscontainer runbefore the sandbox ever starts.writable_tmpfsis not about the flag being accepted — Apple accepts--tmpfs— but about what it does: combined with--read-onlythe mount is NOT writable there, so a read-only rootfs leaves no usable scratch.Anything a runtime cannot honor is reported through #hardening_gaps rather than dropped quietly: running with weaker isolation, or with a workspace you cannot write to, must be visible.
{ docker: {security_opt: true, pids_limit: true, writable_tmpfs: true}, apple: {security_opt: false, pids_limit: false, writable_tmpfs: false} }.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). -
#hardening_gaps ⇒ Object
Hardening the caller asked for that this runtime cannot express, as short human-readable strings.
-
#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, creating parent directories first.
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.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/nexo/sandboxes/container.rb', line 66 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.
61 62 63 |
# File 'lib/nexo/sandboxes/container.rb', line 61 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.
61 62 63 |
# File 'lib/nexo/sandboxes/container.rb', line 61 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.
61 62 63 |
# File 'lib/nexo/sandboxes/container.rb', line 61 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
(+
143 144 145 146 147 148 |
# File 'lib/nexo/sandboxes/container.rb', line 143 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.
125 126 127 128 129 |
# File 'lib/nexo/sandboxes/container.rb', line 125 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 |
#hardening_gaps ⇒ Object
Hardening the caller asked for that this runtime cannot express, as short
human-readable strings. Empty on :docker. Callers that require a guarantee
should check this rather than assume every runtime honors every knob.
153 154 155 156 157 158 159 160 161 162 |
# File 'lib/nexo/sandboxes/container.rb', line 153 def hardening_gaps gaps = [] gaps << "--security-opt no-new-privileges is not supported by #{@runtime}" unless capable?(:security_opt) gaps << "--pids-limit is not supported by #{@runtime}" if @pids_limit && !capable?(:pids_limit) if @readonly_rootfs && !capable?(:writable_tmpfs) gaps << "#{@runtime} --tmpfs is not writable under --read-only; " \ "set readonly_rootfs: false or add a :rw bind for a writable workspace" end gaps 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).
167 168 169 170 |
# File 'lib/nexo/sandboxes/container.rb', line 167 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.
182 183 184 185 186 187 188 189 190 |
# File 'lib/nexo/sandboxes/container.rb', line 182 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.
94 95 96 97 98 99 |
# File 'lib/nexo/sandboxes/container.rb', line 94 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.
134 135 136 |
# File 'lib/nexo/sandboxes/container.rb', line 134 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).
174 175 176 |
# File 'lib/nexo/sandboxes/container.rb', line 174 def supports?(cap) %i[read write shell glob].include?(cap) end |
#write(path, content) ⇒ Object
Writes content to path (guarded) inside the container, creating parent
directories first. Content travels on stdin, never interpolated into the argv,
so arbitrary bytes are safe.
Matches Local#write on both counts, which it previously did not: without the
mkdir a nested path failed with "Directory nonexistent", and because the
exit status was discarded the caller was told the write had succeeded. Raises
IOError on failure, mirroring how #read raises Errno::ENOENT.
109 110 111 112 113 114 115 |
# File 'lib/nexo/sandboxes/container.rb', line 109 def write(path, content) full = guard_path(path) out = exec_stdin!(content, "sh", "-c", 'mkdir -p "$(dirname "$0")" && cat > "$0"', full) raise IOError, "container write failed: #{path}: #{out[:stderr].strip}" unless out[:status].zero? full end |