Class: Nexo::Sandbox

Inherits:
Object
  • Object
show all
Defined in:
lib/nexo/sandbox.rb

Overview

The execution-environment seam. A sandbox is where an agent's tools actually touch files and run commands; swapping the sandbox swaps the whole execution context (in-memory, host, or — later — remote) by constructor injection.

Concrete sandboxes implement the four-method contract below. The base class raises NotImplementedError for each so an incomplete subclass fails loudly.

See Sandboxes::Virtual (default, zero host access) and Sandboxes::Local (host filesystem + shell, guarded).

Constant Summary collapse

PROBE_COMMANDS =

Commands the default #environment probe looks for. Deliberately short — each entry costs a command -v plus one --version when found — and extensible per call for anything else a skill's script might need.

%w[ruby python3 node sh].freeze
PROBE_SCRIPT =

One POSIX sh script, no interpreter required on the far side, so the probe works on a busybox image. command -v locates each command and a single --version reports it; the format placeholder is filled by #environment.

<<~SH
  printf 'locale=%%s\\n' "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
  for c in %<commands>s; do
    p=$(command -v "$c" 2>/dev/null) || continue
    v=$("$c" --version 2>/dev/null | head -1)
    printf 'cmd=%%s\\t%%s\\t%%s\\n' "$c" "$p" "$v"
  done
SH

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.empty_environment ⇒ Object

The shape #environment always answers with, built fresh on every call. Deliberately a method and not a frozen constant: the :commands Hash is mutated while parsing, and CONST.dup is shallow — sharing one inner Hash let every probe accumulate into the constant, so a sandbox with no shell answered with the previous sandbox's findings. :error is nil on a successful probe and carries the reason when one could not be run.



75
76
77
# File 'lib/nexo/sandbox.rb', line 75

def self.empty_environment
  {commands: {}, locale: nil, error: nil}
end

Instance Method Details

#close ⇒ Object

Releases any resources the sandbox holds. No-op by default.



36
37
38
# File 'lib/nexo/sandbox.rb', line 36

def close
  nil
end

#environment(commands: PROBE_COMMANDS) ⇒ Object

What this execution environment actually provides, as data:

sandbox.environment
# => { commands: { "ruby" => { path: "/usr/local/bin/ruby", version: "4.0.0" } },
#      locale: "C.UTF-8" }

#instructions describes the environment for the model; this is the same question answered for code, so a caller can check before staging a skill's script rather than discovering the answer as a stack trace several turns in. A container typically has no locale at all, under which Ruby's default external encoding is US-ASCII and a bare File.read on a UTF-8 file raises — and an image can carry a full Ruby toolchain and still report no locale, so the two are reported as independent axes.

Deliberately coarse: commands on PATH and the locale, never packages. Gems, wheels and npm modules belong to whoever builds the image, and modelling them here would be a cross-language dependency resolver competing with the manifest every ecosystem already has.

Costs one #shell round trip and is memoized for the sandbox's lifetime (0.14s on :local, 0.25–0.48s on a container, measured). A sandbox with no shell A sandbox with no shell (Virtual) reports empty. A probe that fails for any other reason — the container would not start, the client is unreachable — also reports empty, but carries the reason under :error: this is diagnostics and must never be the reason a run dies, yet "I probed and found nothing" and "I could not probe" are different answers and a caller building an error message needs to tell them apart.



120
121
122
123
# File 'lib/nexo/sandbox.rb', line 120

def environment(commands: PROBE_COMMANDS)
  @environment ||= {}
  @environment[commands] ||= probe_environment(commands)
end

#glob(pattern) ⇒ Object

Returns the paths matching the glob pattern.

Raises:

  • (NotImplementedError)


31
32
33
# File 'lib/nexo/sandbox.rb', line 31

def glob(pattern)
  raise NotImplementedError
end

#instructions ⇒ Object

A short, plain-text description of the execution environment (cwd, host access, network) for the agent to inject into the system prompt. Base returns nil — inject nothing. Real-filesystem sandboxes (Local, Container) override this so a weak tool-caller knows where it runs.



44
45
46
# File 'lib/nexo/sandbox.rb', line 44

def instructions
  nil
end

#mtime(path) ⇒ Object

The last-modified time of path, used by the read-before-write + stale guard for real-filesystem sandboxes. Base returns nil (no external mutation to guard against, e.g. Virtual), which disables the guard.



60
61
62
# File 'lib/nexo/sandbox.rb', line 60

def mtime(path)
  nil
end

#read(path) ⇒ Object

Returns the contents of path as a String.

Raises:

  • (NotImplementedError)


15
16
17
# File 'lib/nexo/sandbox.rb', line 15

def read(path)
  raise NotImplementedError
end

#shell(command, timeout: 30) ⇒ Object

Runs command and returns { stdout:, stderr:, status: } (status is the integer exit code). timeout is in seconds.

Raises:

  • (NotImplementedError)


26
27
28
# File 'lib/nexo/sandbox.rb', line 26

def shell(command, timeout: 30)
  raise NotImplementedError
end

#supports?(capability) ⇒ Boolean

Whether the sandbox supports capability (one of :read, :write, :glob, :shell). The base supports everything but :shell (an in-memory sandbox has no process to run a command in), so an agent only attaches the Shell tool when the sandbox reports it. Real-process sandboxes (Local, Container) override to add :shell.

Returns:

  • (Boolean)


53
54
55
# File 'lib/nexo/sandbox.rb', line 53

def supports?(capability)
  capability != :shell
end

#write(path, content) ⇒ Object

Writes content to path.

Raises:

  • (NotImplementedError)


20
21
22
# File 'lib/nexo/sandbox.rb', line 20

def write(path, content)
  raise NotImplementedError
end