Class: Nexo::Sandboxes::Container

Inherits:
Nexo::Sandbox show all
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 with network:.
  • --cap-drop ALL — restore individual caps with cap_add:.
  • --read-only rootfs + an ephemeral --tmpfs <cwd>:rw writable scratch — disable with readonly_rootfs: false.
  • --security-opt no-new-privileges (always on).
  • --pids-limit 512 fork-bomb guard — override/omit with pids_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 container 1.2.2 on macOS and Docker 29.4: Apple's CLI rejects --security-opt and --pids-limit with "Unknown option", which aborts container run before the sandbox ever starts.

writable_tmpfs is not about the flag being accepted — Apple accepts --tmpfs — but about what it does: combined with --read-only the 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

Instance Method Summary collapse

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.

Raises:



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

#cwdObject (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

#imageObject (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

#runtimeObject (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

#closeObject

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 (+ rm -f +): the memoized id is exact and unambiguous.



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_gapsObject

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

#instructionsObject

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.

Raises:

  • (Errno::ENOENT)


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).

Returns:

  • (Boolean)


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.

Raises:

  • (IOError)


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