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

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:



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

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

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

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

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



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

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



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.

Raises:

  • (Errno::ENOENT)


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

Returns:

  • (Boolean)


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