Class: Nexo::Sandboxes::Local

Inherits:
Nexo::Sandbox show all
Defined in:
lib/nexo/sandboxes/local.rb

Overview

Host filesystem + shell sandbox, for trusted dev/CI use. The developer opts into this explicitly (the default is Virtual); two guards keep a model-driven agent contained:

  • Path-escape guard — every +read+/+write+ path is expanded against cwd and must stay inside it, otherwise SecurityError is raised.
  • Narrowed ENV — the shell sees only PATH, HOME, LANG (plus explicit env: additions), never the full process environment.

Under a fiber reactor (Spec 5) these blocking file/shell syscalls would stall every other concurrent fiber. When Nexo.config.concurrency == :async each operation is offloaded to a worker thread (see #offload); otherwise it runs inline, byte-for-byte the Spec 1 behavior with zero overhead. The offload never changes return values or the security properties below.

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Nexo::Sandbox

#close

Constructor Details

#initialize(cwd: Dir.pwd, env: {}) ⇒ Local

Roots the sandbox at cwd (expanded, default Dir.pwd) and narrows the shell environment to +PATH+/+HOME+/+LANG+ plus any explicit env: entries.



29
30
31
32
33
34
35
36
# File 'lib/nexo/sandboxes/local.rb', line 29

def initialize(cwd: Dir.pwd, env: {})
  @cwd = File.expand_path(cwd)
  # The physical root (symlinks resolved), used by the escape guards so a
  # symlink inside cwd can't leak a target outside it.
  @real_cwd = File.exist?(@cwd) ? File.realpath(@cwd) : @cwd
  # Deliberately narrow env — never hand a model-driven shell the whole ENV.
  @env = ENV.to_h.slice("PATH", "HOME", "LANG").merge(env)
end

Instance Attribute Details

#cwdObject (readonly)

The expanded workspace root; every path is guarded to stay inside it.



25
26
27
# File 'lib/nexo/sandboxes/local.rb', line 25

def cwd
  @cwd
end

Instance Method Details

#glob(pattern) ⇒ Object



42
43
44
45
46
47
48
49
50
51
# File 'lib/nexo/sandboxes/local.rb', line 42

def glob(pattern)
  # Guard the pattern itself so it can't escape via "../.." (metacharacters
  # like * are preserved by expand_path), then drop any match whose real
  # path (symlinks resolved) lands outside the sandbox.
  full = File.expand_path(pattern, @cwd)
  unless within?(full, @cwd)
    raise SecurityError, "glob pattern escapes sandbox: #{pattern}"
  end
  offload { Dir.glob(full) }.select { |match| real_within?(match) }
end

#instructionsObject

A plain-text description of this host environment for the agent's system prompt: the host cwd, that the real host filesystem and shell are reachable, and that access is guarded to cwd.



64
65
66
67
# File 'lib/nexo/sandboxes/local.rb', line 64

def instructions
  "You run on the host machine, cwd #{@cwd}. The real host filesystem " \
    "and shell are reachable; file access is guarded to #{@cwd}."
end

#mtime(path) ⇒ Object

The last-modified time of path (guarded against escaping cwd), or nil when the file is absent — so a new-file write skips the read-before-write guard.



78
79
80
81
# File 'lib/nexo/sandboxes/local.rb', line 78

def mtime(path)
  full = absolute(path)
  File.exist?(full) ? File.mtime(full) : nil
end

#read(path) ⇒ Object



38
39
40
# File 'lib/nexo/sandboxes/local.rb', line 38

def read(path)
  offload { File.read(absolute(path)) }
end

#shell(command, timeout: 30) ⇒ Object



83
84
85
86
87
88
89
90
91
92
# File 'lib/nexo/sandboxes/local.rb', line 83

def shell(command, timeout: 30)
  offload do
    # ruby_llm's target Ruby has no `timeout:` kwarg on Open3.capture3
    # (verified), so the wall-clock bound lives in Timeout.timeout.
    out, err, status = Timeout.timeout(timeout) do
      Open3.capture3(@env, command, chdir: @cwd)
    end
    {stdout: out, stderr: err, status: status.exitstatus}
  end
end

#supports?(cap) ⇒ Boolean

Supports all four capabilities — unlike Virtual, a real process runs the shell command here.

Returns:

  • (Boolean)


71
72
73
# File 'lib/nexo/sandboxes/local.rb', line 71

def supports?(cap)
  %i[read write shell glob].include?(cap)
end

#write(path, content) ⇒ Object



53
54
55
56
57
58
59
# File 'lib/nexo/sandboxes/local.rb', line 53

def write(path, content)
  offload do
    full = absolute(path)
    FileUtils.mkdir_p(File.dirname(full))
    File.write(full, content)
  end
end