nexo_ai 0.1.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.document +3 -0
- data/CHANGELOG.md +232 -0
- data/README.md +75 -4
- data/Rakefile +29 -0
- data/app/views/nexo/_event.html.erb +1 -0
- data/docs/concurrency.md +94 -0
- data/docs/durable-workflows.md +184 -0
- data/docs/getting-started.md +103 -0
- data/docs/loops.md +93 -0
- data/docs/mcp.md +144 -0
- data/docs/permissions.md +47 -0
- data/docs/rails.md +129 -0
- data/docs/sandboxes.md +290 -0
- data/docs/sessions.md +99 -0
- data/docs/skills.md +68 -0
- data/docs/tools.md +38 -0
- data/docs/web.md +130 -0
- data/docs/workflows.md +255 -0
- data/examples/README.md +51 -0
- data/examples/approval_agent.rb +64 -0
- data/examples/approval_workflow.rb +62 -0
- data/examples/artifact_from_template.rb +54 -0
- data/examples/chat_session.rb +49 -0
- data/examples/code_reviewer.rb +64 -0
- data/examples/container_review.rb +52 -0
- data/examples/inbox_digest.rb +62 -0
- data/examples/inbox_digest_http.rb +69 -0
- data/examples/inbox_digest_task.rb +40 -0
- data/examples/mcp_filesystem.rb +50 -0
- data/examples/news_search.rb +49 -0
- data/examples/news_summary.rb +37 -0
- data/examples/rails_usage.md +141 -0
- data/examples/skills/email_triage/SKILL.md +47 -0
- data/examples/skills/news_summary/SKILL.md +37 -0
- data/examples/skills/ruby-code-review/SKILL.md +61 -0
- data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
- data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
- data/lib/generators/nexo/install/install_generator.rb +35 -0
- data/lib/generators/nexo/install/templates/nexo.rb +19 -0
- data/lib/generators/nexo/skill/skill_generator.rb +45 -0
- data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
- data/lib/generators/nexo/state/state_generator.rb +37 -0
- data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
- data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
- data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
- data/lib/nexo/agent.rb +423 -0
- data/lib/nexo/concurrent.rb +78 -0
- data/lib/nexo/configuration.rb +82 -0
- data/lib/nexo/engine.rb +51 -0
- data/lib/nexo/loop.rb +30 -0
- data/lib/nexo/loops/agent_sdk.rb +68 -0
- data/lib/nexo/loops/ruby_llm.rb +66 -0
- data/lib/nexo/mcp/gated_tool.rb +70 -0
- data/lib/nexo/mcp.rb +96 -0
- data/lib/nexo/output_truncator.rb +41 -0
- data/lib/nexo/permissions.rb +162 -0
- data/lib/nexo/read_tracker.rb +32 -0
- data/lib/nexo/run_store.rb +162 -0
- data/lib/nexo/sandbox.rb +64 -0
- data/lib/nexo/sandboxes/container.rb +293 -0
- data/lib/nexo/sandboxes/local.rb +157 -0
- data/lib/nexo/sandboxes/remote.rb +77 -0
- data/lib/nexo/sandboxes/virtual.rb +42 -0
- data/lib/nexo/sandboxes.rb +43 -0
- data/lib/nexo/session.rb +152 -0
- data/lib/nexo/skills.rb +54 -0
- data/lib/nexo/tools/fetch.rb +133 -0
- data/lib/nexo/tools/glob.rb +28 -0
- data/lib/nexo/tools/read_file.rb +54 -0
- data/lib/nexo/tools/shell.rb +35 -0
- data/lib/nexo/tools/web_search.rb +81 -0
- data/lib/nexo/tools/write_file.rb +61 -0
- data/lib/nexo/turbo_broadcaster.rb +41 -0
- data/lib/nexo/version.rb +2 -1
- data/lib/nexo/workflow.rb +705 -0
- data/lib/nexo/workflow_job.rb +42 -0
- data/lib/nexo/workflow_run.rb +128 -0
- data/lib/nexo.rb +138 -1
- data/lib/tasks/nexo.rake +17 -0
- data/sig/nexo/agent.rbs +23 -0
- data/sig/nexo/output_truncator.rbs +7 -0
- data/sig/nexo/permissions.rbs +15 -0
- data/sig/nexo/read_tracker.rbs +8 -0
- data/sig/nexo/sandbox.rbs +12 -0
- data/sig/nexo/sandboxes/container.rbs +26 -0
- data/sig/nexo/sandboxes/local.rbs +12 -0
- data/sig/nexo/sandboxes/virtual.rbs +7 -0
- data/sig/nexo/sandboxes.rbs +5 -0
- data/sig/nexo/tools.rbs +28 -0
- data/sig/nexo_ai.rbs +22 -1
- metadata +185 -2
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Nexo
|
|
7
|
+
module Sandboxes
|
|
8
|
+
# Runs an agent's tools inside a throwaway OCI container via the +docker+
|
|
9
|
+
# (default) or Apple +container+ CLI — shell-out only, no client gem, no
|
|
10
|
+
# Compose, no image builder. Implements the four-method sandbox contract
|
|
11
|
+
# (+read+/+write+/+shell+/+glob+) plus +close+ by shelling the runtime binary
|
|
12
|
+
# through +Open3+, so a model-driven agent never touches the host filesystem
|
|
13
|
+
# or shell directly.
|
|
14
|
+
#
|
|
15
|
+
# Hardened by default, every knob an explicit opt-out:
|
|
16
|
+
#
|
|
17
|
+
# * +--network none+ (no egress) — loosen with +network:+.
|
|
18
|
+
# * +--cap-drop ALL+ — restore individual caps with +cap_add:+.
|
|
19
|
+
# * +--read-only+ rootfs + an ephemeral +--tmpfs <cwd>:rw+ writable scratch —
|
|
20
|
+
# disable with +readonly_rootfs: false+.
|
|
21
|
+
# * +--security-opt no-new-privileges+ (always on).
|
|
22
|
+
# * +--pids-limit 512+ fork-bomb guard — override/omit with +pids_limit:+.
|
|
23
|
+
# * Host binds mounted **read-only** by default; a bind is writable only when
|
|
24
|
+
# the developer says so per-bind (+{ to:, mode: :rw }+).
|
|
25
|
+
#
|
|
26
|
+
# Non-root is NOT forced — the image's own uid is respected; +user:+ is an
|
|
27
|
+
# opt-in defense-in-depth. The hardening above applies regardless of uid.
|
|
28
|
+
#
|
|
29
|
+
# The container starts **lazily** on first tool use and its id is memoized.
|
|
30
|
+
# Ephemeral by default: +close+ force-removes the container. With +name:+ +
|
|
31
|
+
# +reconnect: true+ the container is reused across sandboxes and +close+ leaves
|
|
32
|
+
# it in place.
|
|
33
|
+
#
|
|
34
|
+
# Argv construction is pure (+run_argv+ and the exec argv builders) so the
|
|
35
|
+
# offline suite asserts the exact +Open3+ argv with no daemon. Live runs are
|
|
36
|
+
# +NEXO_LIVE+-gated smoke, never a core-suite dependency.
|
|
37
|
+
class Container < Sandbox
|
|
38
|
+
# Maps the +runtime:+ option onto the host CLI binary. Frozen — the only two
|
|
39
|
+
# supported local runtimes in v1.
|
|
40
|
+
RUNTIMES = {docker: "docker", apple: "container"}.freeze
|
|
41
|
+
|
|
42
|
+
# The container working directory (default +/workspace+, a container path),
|
|
43
|
+
# the selected +runtime+ (+:docker+ / +:apple+), and the required +image+.
|
|
44
|
+
attr_reader :cwd, :runtime, :image
|
|
45
|
+
|
|
46
|
+
# +image:+ is required (no default image). +runtime:+ selects the binary.
|
|
47
|
+
# Every other keyword loosens one hardening default; see the class docs and
|
|
48
|
+
# the README hardened-defaults table.
|
|
49
|
+
def initialize(image: nil, runtime: :docker, cwd: "/workspace", binds: {},
|
|
50
|
+
network: :none, cap_add: [], memory: nil, cpus: nil, pids_limit: 512,
|
|
51
|
+
user: nil, env: {}, name: nil, reconnect: false, readonly_rootfs: true)
|
|
52
|
+
raise ConfigurationError, "container sandbox requires image:" if image.nil?
|
|
53
|
+
|
|
54
|
+
@bin = RUNTIMES.fetch(runtime) do
|
|
55
|
+
raise ConfigurationError, "unknown container runtime: #{runtime.inspect}"
|
|
56
|
+
end
|
|
57
|
+
@runtime = runtime
|
|
58
|
+
@image = image
|
|
59
|
+
@cwd = cwd
|
|
60
|
+
@binds = binds
|
|
61
|
+
@network = network
|
|
62
|
+
@cap_add = cap_add
|
|
63
|
+
@memory = memory
|
|
64
|
+
@cpus = cpus
|
|
65
|
+
@pids_limit = pids_limit
|
|
66
|
+
@user = user
|
|
67
|
+
@env = env
|
|
68
|
+
@name = name || "nexo-#{Nexo.generate_run_id}"
|
|
69
|
+
@reconnect = reconnect
|
|
70
|
+
@readonly_rootfs = readonly_rootfs
|
|
71
|
+
@cid = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns the contents of +path+ (guarded against escaping +cwd+) as a
|
|
75
|
+
# String. Raises +Errno::ENOENT+ when the file is absent, matching the other
|
|
76
|
+
# sandboxes' read contract.
|
|
77
|
+
def read(path)
|
|
78
|
+
out = exec!("cat", "--", guard_path(path))
|
|
79
|
+
raise Errno::ENOENT, path unless out[:status].zero?
|
|
80
|
+
|
|
81
|
+
out[:stdout]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Writes +content+ to +path+ (guarded) inside the container. Content travels
|
|
85
|
+
# on stdin, never interpolated into the argv, so arbitrary bytes are safe.
|
|
86
|
+
def write(path, content)
|
|
87
|
+
exec_stdin!(content, "sh", "-c", 'cat > "$0"', guard_path(path))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the container paths matching the glob +pattern+ (guarded). Empty
|
|
91
|
+
# output yields +[]+.
|
|
92
|
+
#
|
|
93
|
+
# The guarded pattern is passed as a positional parameter (+$1+), NEVER
|
|
94
|
+
# interpolated into the script text, so shell metacharacters in a
|
|
95
|
+
# model-supplied pattern (+;+, +$()+, backticks) are inert data — +for f in
|
|
96
|
+
# $1+ still performs pathname (glob) expansion, but nothing in +$1+ is ever
|
|
97
|
+
# parsed as a command.
|
|
98
|
+
def glob(pattern)
|
|
99
|
+
script = 'for f in $1; do [ -e "$f" ] && echo "$f"; done'
|
|
100
|
+
out = exec!("sh", "-c", script, "sh", guard_path(pattern))
|
|
101
|
+
out[:stdout].split("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Runs +command+ inside the container and returns
|
|
105
|
+
# +{ stdout:, stderr:, status: }+ (status is the integer exit code). The
|
|
106
|
+
# wall-clock bound lives in +Timeout.timeout+ around +Open3.capture3+.
|
|
107
|
+
def shell(command, timeout: 30)
|
|
108
|
+
exec!("sh", "-c", command, timeout: timeout)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Force-removes the container (ephemeral default) and clears the memo.
|
|
112
|
+
# Idempotent: safe with nothing started or called more than once. With
|
|
113
|
+
# +reconnect: true+ the container is left in place for a later sandbox to
|
|
114
|
+
# reattach by its exact identity label. Teardown stays id-based
|
|
115
|
+
# (+<bin> rm -f <cid>+): the memoized id is exact and unambiguous.
|
|
116
|
+
def close
|
|
117
|
+
if @cid && !@reconnect
|
|
118
|
+
system(@bin, "rm", "-f", @cid, out: File::NULL, err: File::NULL)
|
|
119
|
+
end
|
|
120
|
+
@cid = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A short, human-readable description of the execution environment for the
|
|
124
|
+
# agent to inject later (consumed by the Refinements spec; until then the
|
|
125
|
+
# method simply exists and is correct).
|
|
126
|
+
def instructions
|
|
127
|
+
"You run inside a #{@runtime} container (image #{@image}), cwd #{@cwd}, " \
|
|
128
|
+
"network #{@network}. Only #{@cwd} and any :rw binds are writable."
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# +true+ for the four sandbox capabilities. Unlike Virtual, +:shell+ is
|
|
132
|
+
# supported here (a real process runs the command).
|
|
133
|
+
def supports?(cap)
|
|
134
|
+
%i[read write shell glob].include?(cap)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The last-modified time of +path+ (guarded) inside the container, read by
|
|
138
|
+
# shelling +stat -c %Y+ (GNU coreutils epoch seconds). Returns a +Time+, or
|
|
139
|
+
# +nil+ when the file is absent — so a new-file write skips the
|
|
140
|
+
# read-before-write guard. Used only by the R4 clobber guard.
|
|
141
|
+
def mtime(path)
|
|
142
|
+
out = exec!("stat", "-c", "%Y", "--", guard_path(path))
|
|
143
|
+
return nil unless out[:status].zero?
|
|
144
|
+
|
|
145
|
+
epoch = out[:stdout].strip
|
|
146
|
+
epoch.empty? ? nil : Time.at(Integer(epoch))
|
|
147
|
+
rescue ArgumentError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# The exact +run+ argv, built purely so the offline suite can assert it with
|
|
154
|
+
# no daemon. Hardened flags come first; every loosening knob appends only
|
|
155
|
+
# when set.
|
|
156
|
+
def run_argv
|
|
157
|
+
# The identity label goes immediately after --name and before every
|
|
158
|
+
# loosening/hardening knob — it is what makes an *exact* reconnect
|
|
159
|
+
# possible (see #reconnect_argv). The key is exactly +nexo.sandbox.id+.
|
|
160
|
+
argv = [@bin, "run", "-d", "--name", @name,
|
|
161
|
+
"--label", "nexo.sandbox.id=#{@name}",
|
|
162
|
+
"--network", @network.to_s,
|
|
163
|
+
"--cap-drop", "ALL",
|
|
164
|
+
"--security-opt", "no-new-privileges"]
|
|
165
|
+
@cap_add.each { |cap| argv += ["--cap-add", cap.to_s] }
|
|
166
|
+
argv += ["--read-only", "--tmpfs", "#{@cwd}:rw"] if @readonly_rootfs
|
|
167
|
+
argv += ["--pids-limit", @pids_limit.to_s] unless @pids_limit.nil?
|
|
168
|
+
argv += ["--memory", @memory.to_s] unless @memory.nil?
|
|
169
|
+
argv += ["--cpus", @cpus.to_s] unless @cpus.nil?
|
|
170
|
+
argv += ["--user", @user.to_s] unless @user.nil?
|
|
171
|
+
@env.each { |key, value| argv += ["-e", "#{key}=#{value}"] }
|
|
172
|
+
@binds.each { |host, dst| argv += ["-v", bind_spec(host, dst)] }
|
|
173
|
+
# +tail -f /dev/null+ is busybox-portable (Alpine, Debian-slim, ruby-slim
|
|
174
|
+
# all hold open); +sleep infinity+ is NOT busybox-safe and exits at once
|
|
175
|
+
# on a slim image, failing every later +exec+.
|
|
176
|
+
argv += ["-w", @cwd, @image, "tail", "-f", "/dev/null"]
|
|
177
|
+
argv
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Builds one +-v host:ctr:mode+ spec. Binds default to +:ro+; a Hash form
|
|
181
|
+
# +{ to:, mode: :rw }+ makes a single bind writable.
|
|
182
|
+
def bind_spec(host, dst)
|
|
183
|
+
to, mode = dst.is_a?(Hash) ? [dst.fetch(:to), dst.fetch(:mode, :ro)] : [dst, :ro]
|
|
184
|
+
"#{host}:#{to}:#{mode}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Lazily starts (or, when reconnecting, reuses) the container and memoizes
|
|
188
|
+
# its id. Raises Nexo::Error on a start failure.
|
|
189
|
+
def ensure_started!
|
|
190
|
+
return @cid if @cid
|
|
191
|
+
|
|
192
|
+
@cid = reconnect_existing if @reconnect
|
|
193
|
+
return @cid if @cid
|
|
194
|
+
|
|
195
|
+
out, err, status = Open3.capture3(*run_argv)
|
|
196
|
+
raise Nexo::Error, "container start failed: #{err.strip}" unless status.success?
|
|
197
|
+
|
|
198
|
+
@cid = out.strip
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Reconnect path: find the container previously created under +@name+ by its
|
|
202
|
+
# EXACT identity label (never a name substring) and, if present, restart it
|
|
203
|
+
# (a no-op when already running) and reuse its id.
|
|
204
|
+
#
|
|
205
|
+
# * 0 matches -> +nil+ (so +ensure_started!+ falls through to a fresh +run+).
|
|
206
|
+
# * 1 match -> +start+ it and reuse that id.
|
|
207
|
+
# * >1 matches -> raise +Nexo::Error+; never guess which one to attach.
|
|
208
|
+
#
|
|
209
|
+
# Only genuinely unexpected errors (a daemon/CLI failure) fail open to the
|
|
210
|
+
# create path; the ambiguity raise and the Apple-posture +ConfigurationError+
|
|
211
|
+
# (both +Nexo::Error+ subclasses) are re-raised, never swallowed.
|
|
212
|
+
#
|
|
213
|
+
# Runtime isolation is inherent: the query shells the runtime-specific +@bin+
|
|
214
|
+
# (docker vs. Apple +container+), and each runtime keeps its own id namespace,
|
|
215
|
+
# so a +:docker+ container can never be reattached by an +:apple+ sandbox or
|
|
216
|
+
# vice versa.
|
|
217
|
+
def reconnect_existing
|
|
218
|
+
# Apple reconnect posture (Spec 20 R4/Q4): Apple's +container+ CLI has no
|
|
219
|
+
# live-verified exact +label=+ filter, and a name substring match is unsafe,
|
|
220
|
+
# so reconnect on +:apple+ raises rather than risk attaching the wrong
|
|
221
|
+
# container. Wire the verified mechanism here once Group 0 confirms one.
|
|
222
|
+
if @runtime == :apple
|
|
223
|
+
raise ConfigurationError,
|
|
224
|
+
"reconnect: true is not supported on the Apple :container runtime — " \
|
|
225
|
+
"it has no verified exact label filter (Spec 20 Q4); use runtime: :docker " \
|
|
226
|
+
"for reconnect, or run an ephemeral :apple sandbox (reconnect: false)"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
out, _err, status = Open3.capture3(*reconnect_argv)
|
|
230
|
+
return nil unless status.success?
|
|
231
|
+
|
|
232
|
+
ids = out.strip.split("\n").reject(&:empty?)
|
|
233
|
+
return nil if ids.empty?
|
|
234
|
+
if ids.size > 1
|
|
235
|
+
raise Nexo::Error, "ambiguous reconnect: #{ids.size} containers labeled #{@name}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
system(@bin, "start", ids.first, out: File::NULL, err: File::NULL)
|
|
239
|
+
ids.first
|
|
240
|
+
rescue Nexo::Error
|
|
241
|
+
raise
|
|
242
|
+
rescue
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# The exact reconnect query argv, built purely so the offline suite can assert
|
|
247
|
+
# it with no daemon. Matches by the exact identity label set in +run_argv+
|
|
248
|
+
# (+label=nexo.sandbox.id=<name>+), NOT a +name=<name>+ substring filter.
|
|
249
|
+
def reconnect_argv
|
|
250
|
+
[@bin, "ps", "-aqf", "label=nexo.sandbox.id=#{@name}"]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Runs +cmd+ inside the started container, wall-clock bounded. Returns the
|
|
254
|
+
# +{ stdout:, stderr:, status: }+ shape (integer exit code) proven by
|
|
255
|
+
# Local#shell; +Open3.capture3+ has no +timeout:+ kwarg on the target Ruby.
|
|
256
|
+
def exec!(*cmd, timeout: 30)
|
|
257
|
+
ensure_started!
|
|
258
|
+
out, err, status = Timeout.timeout(timeout) do
|
|
259
|
+
Open3.capture3(*exec_argv(*cmd))
|
|
260
|
+
end
|
|
261
|
+
{stdout: out, stderr: err, status: status.exitstatus}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# As #exec!, but feeds +data+ to the command on stdin (used by +write+ so
|
|
265
|
+
# file contents never enter the argv). +-i+ keeps stdin open.
|
|
266
|
+
def exec_stdin!(data, *cmd, timeout: 30)
|
|
267
|
+
ensure_started!
|
|
268
|
+
out, err, status = Timeout.timeout(timeout) do
|
|
269
|
+
Open3.capture3(*exec_argv(*cmd, interactive: true), stdin_data: data)
|
|
270
|
+
end
|
|
271
|
+
{stdout: out, stderr: err, status: status.exitstatus}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The +exec+ argv (pure, offline-assertable). +interactive:+ inserts +-i+.
|
|
275
|
+
def exec_argv(*cmd, interactive: false)
|
|
276
|
+
argv = [@bin, "exec"]
|
|
277
|
+
argv << "-i" if interactive
|
|
278
|
+
argv += [@cid, *cmd]
|
|
279
|
+
argv
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Local's path-escape guard, applied to the CONTAINER path: expand against
|
|
283
|
+
# +cwd+ and raise +SecurityError+ if it escapes. Runs before any +exec+.
|
|
284
|
+
def guard_path(path)
|
|
285
|
+
full = File.expand_path(path, @cwd)
|
|
286
|
+
unless full == @cwd || full.start_with?(@cwd + File::SEPARATOR)
|
|
287
|
+
raise SecurityError, "path escapes sandbox: #{path}"
|
|
288
|
+
end
|
|
289
|
+
full
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Nexo
|
|
8
|
+
module Sandboxes
|
|
9
|
+
# Host filesystem + shell sandbox, for trusted dev/CI use. The developer opts
|
|
10
|
+
# into this explicitly (the default is Virtual); two guards keep a
|
|
11
|
+
# model-driven agent contained:
|
|
12
|
+
#
|
|
13
|
+
# * Path-escape guard — every +read+/+write+ path is expanded against +cwd+
|
|
14
|
+
# and must stay inside it, otherwise +SecurityError+ is raised.
|
|
15
|
+
# * Narrowed ENV — the shell sees only PATH, HOME, LANG (plus explicit
|
|
16
|
+
# +env:+ additions), never the full process environment.
|
|
17
|
+
#
|
|
18
|
+
# Under a fiber reactor (Spec 5) these blocking file/shell syscalls would
|
|
19
|
+
# stall every other concurrent fiber. When +Nexo.config.concurrency == :async+
|
|
20
|
+
# each operation is offloaded to a worker thread (see #offload); otherwise
|
|
21
|
+
# it runs inline, byte-for-byte the Spec 1 behavior with zero overhead. The
|
|
22
|
+
# offload never changes return values or the security properties below.
|
|
23
|
+
class Local < Sandbox
|
|
24
|
+
# The expanded workspace root; every path is guarded to stay inside it.
|
|
25
|
+
attr_reader :cwd
|
|
26
|
+
|
|
27
|
+
# Roots the sandbox at +cwd+ (expanded, default +Dir.pwd+) and narrows the
|
|
28
|
+
# shell environment to +PATH+/+HOME+/+LANG+ plus any explicit +env:+ entries.
|
|
29
|
+
def initialize(cwd: Dir.pwd, env: {})
|
|
30
|
+
@cwd = File.expand_path(cwd)
|
|
31
|
+
# The physical root (symlinks resolved), used by the escape guards so a
|
|
32
|
+
# symlink inside cwd can't leak a target outside it.
|
|
33
|
+
@real_cwd = File.exist?(@cwd) ? File.realpath(@cwd) : @cwd
|
|
34
|
+
# Deliberately narrow env — never hand a model-driven shell the whole ENV.
|
|
35
|
+
@env = ENV.to_h.slice("PATH", "HOME", "LANG").merge(env)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read(path)
|
|
39
|
+
offload { File.read(absolute(path)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def glob(pattern)
|
|
43
|
+
# Guard the pattern itself so it can't escape via "../.." (metacharacters
|
|
44
|
+
# like * are preserved by expand_path), then drop any match whose real
|
|
45
|
+
# path (symlinks resolved) lands outside the sandbox.
|
|
46
|
+
full = File.expand_path(pattern, @cwd)
|
|
47
|
+
unless within?(full, @cwd)
|
|
48
|
+
raise SecurityError, "glob pattern escapes sandbox: #{pattern}"
|
|
49
|
+
end
|
|
50
|
+
offload { Dir.glob(full) }.select { |match| real_within?(match) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write(path, content)
|
|
54
|
+
offload do
|
|
55
|
+
full = absolute(path)
|
|
56
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
57
|
+
File.write(full, content)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A plain-text description of this host environment for the agent's system
|
|
62
|
+
# prompt: the host cwd, that the real host filesystem and shell are
|
|
63
|
+
# reachable, and that access is guarded to +cwd+.
|
|
64
|
+
def instructions
|
|
65
|
+
"You run on the host machine, cwd #{@cwd}. The real host filesystem " \
|
|
66
|
+
"and shell are reachable; file access is guarded to #{@cwd}."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Supports all four capabilities — unlike Virtual, a real process runs
|
|
70
|
+
# the shell command here.
|
|
71
|
+
def supports?(cap)
|
|
72
|
+
%i[read write shell glob].include?(cap)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The last-modified time of +path+ (guarded against escaping +cwd+), or
|
|
76
|
+
# +nil+ when the file is absent — so a new-file write skips the
|
|
77
|
+
# read-before-write guard.
|
|
78
|
+
def mtime(path)
|
|
79
|
+
full = absolute(path)
|
|
80
|
+
File.exist?(full) ? File.mtime(full) : nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def shell(command, timeout: 30)
|
|
84
|
+
offload do
|
|
85
|
+
# ruby_llm's target Ruby has no `timeout:` kwarg on Open3.capture3
|
|
86
|
+
# (verified), so the wall-clock bound lives in Timeout.timeout.
|
|
87
|
+
out, err, status = Timeout.timeout(timeout) do
|
|
88
|
+
Open3.capture3(@env, command, chdir: @cwd)
|
|
89
|
+
end
|
|
90
|
+
{stdout: out, stderr: err, status: status.exitstatus}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Runs a blocking block off the reactor when async concurrency is enabled,
|
|
97
|
+
# inline otherwise. The decision is driven by config, not by
|
|
98
|
+
# +Fiber.scheduler+ detection: under +:async+ the block runs on a worker
|
|
99
|
+
# thread so the reactor keeps serving other fibers; under +:threaded+ (the
|
|
100
|
+
# default) it runs inline — identical to Spec 1, zero overhead.
|
|
101
|
+
#
|
|
102
|
+
# +Async::WorkerPool+ is not available in the installed +async+ (2.x), so
|
|
103
|
+
# the offload primitive is +Thread.new(&block).value+, which always works
|
|
104
|
+
# and re-raises any exception from the block in the calling fiber (so the
|
|
105
|
+
# path-escape +SecurityError+ still propagates unchanged).
|
|
106
|
+
def offload(&block)
|
|
107
|
+
if Nexo.config.concurrency == :async
|
|
108
|
+
thread = Thread.new(&block)
|
|
109
|
+
# #value re-raises any block exception (e.g. the path-escape
|
|
110
|
+
# SecurityError) in the caller; silence the thread's own duplicate
|
|
111
|
+
# report so an expected, re-raised error isn't printed to stderr.
|
|
112
|
+
thread.report_on_exception = false
|
|
113
|
+
thread.value
|
|
114
|
+
else
|
|
115
|
+
block.call
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def absolute(path)
|
|
120
|
+
full = File.expand_path(path, @cwd)
|
|
121
|
+
# Check the PHYSICAL location (symlinks resolved), not the lexical path, so
|
|
122
|
+
# a symlink inside cwd pointing outside it — including a dangling one a
|
|
123
|
+
# write would follow — can't escape the sandbox.
|
|
124
|
+
unless within?(resolved(full), @real_cwd)
|
|
125
|
+
raise SecurityError, "path escapes sandbox: #{path}"
|
|
126
|
+
end
|
|
127
|
+
full
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# True when +path+ is +base+ itself or lies beneath it (lexical prefix).
|
|
131
|
+
def within?(path, base)
|
|
132
|
+
path == base || path.start_with?(base + File::SEPARATOR)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether an existing filesystem entry's real (symlink-resolved) path stays
|
|
136
|
+
# inside the sandbox. A match that vanishes or can't be resolved is excluded.
|
|
137
|
+
def real_within?(path)
|
|
138
|
+
within?(File.realpath(path), @real_cwd)
|
|
139
|
+
rescue
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The physical path a read/write would actually touch: resolve symlinks on
|
|
144
|
+
# the longest existing prefix and re-append the not-yet-existing tail, and
|
|
145
|
+
# follow a dangling symlink leaf to its target (a write would). Lets the
|
|
146
|
+
# escape guard see where the operation really lands, not just its spelling.
|
|
147
|
+
def resolved(full)
|
|
148
|
+
return File.realpath(full) if File.exist?(full)
|
|
149
|
+
return resolved(File.expand_path(File.readlink(full), File.dirname(full))) if File.symlink?(full)
|
|
150
|
+
|
|
151
|
+
parent = File.dirname(full)
|
|
152
|
+
base = File.exist?(parent) ? File.realpath(parent) : resolved(parent)
|
|
153
|
+
File.join(base, File.basename(full))
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Nexo
|
|
6
|
+
module Sandboxes
|
|
7
|
+
# A provider-agnostic remote sandbox: it runs an agent's tools inside some
|
|
8
|
+
# remote container (E2B / Daytona / Modal / Docker / your own) by delegating
|
|
9
|
+
# the four-method Sandbox contract to an injected +client+.
|
|
10
|
+
#
|
|
11
|
+
# The client is any object responding to +read+/+write+/+exec+/+close+. That
|
|
12
|
+
# four-method contract is the entire integration surface — +Remote+ contains
|
|
13
|
+
# ZERO vendor code, so switching providers is swapping the injected object,
|
|
14
|
+
# not changing Nexo. Adapt a vendor client to the contract with a tiny shim
|
|
15
|
+
# (see the README's shim-pattern example).
|
|
16
|
+
#
|
|
17
|
+
# sandbox = Nexo::Sandboxes::Remote.new(client: my_container_client)
|
|
18
|
+
#
|
|
19
|
+
# The client's +exec+ is expected to return the same shape the Sandbox
|
|
20
|
+
# +shell+ contract documents — +{ stdout:, stderr:, status: }+ — so +#shell+
|
|
21
|
+
# passes it straight through and +#glob+ can read +[:stdout]+. Adapting a
|
|
22
|
+
# vendor client to that shape is the shim's job (see the README example).
|
|
23
|
+
#
|
|
24
|
+
# Escalating to +:remote+ is always an explicit choice in user code; the
|
|
25
|
+
# default sandbox stays +:virtual+.
|
|
26
|
+
class Remote < Sandbox
|
|
27
|
+
# Stores any object responding to +read+/+write+/+exec+/+close+.
|
|
28
|
+
def initialize(client:)
|
|
29
|
+
@client = client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reads +path+ via the client.
|
|
33
|
+
def read(path)
|
|
34
|
+
@client.read(path)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Writes +content+ to +path+ via the client.
|
|
38
|
+
def write(path, content)
|
|
39
|
+
@client.write(path, content)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Runs +command+ via the client's +exec+ and returns its result. The client
|
|
43
|
+
# is expected to honor +timeout:+ (seconds) the way the rest of the contract
|
|
44
|
+
# honors the Sandbox shape.
|
|
45
|
+
def shell(command, timeout: 30)
|
|
46
|
+
@client.exec(command, timeout: timeout)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Supports all four capabilities: the injected client runs a real remote
|
|
50
|
+
# process, so — unlike Virtual — an agent on a Remote sandbox gets the Shell
|
|
51
|
+
# tool attached (Agent#chat gates Shell on +supports?(:shell)+).
|
|
52
|
+
def supports?(cap)
|
|
53
|
+
%i[read write shell glob].include?(cap)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Lists paths matching +pattern+ by expanding it remotely and splitting the
|
|
57
|
+
# client's stdout into an array of lines.
|
|
58
|
+
#
|
|
59
|
+
# The pattern is handed to an inner shell as a positional parameter (+$1+),
|
|
60
|
+
# so +for f in $1+ performs the remote glob expansion, while Shellwords
|
|
61
|
+
# keeps both the script and the pattern single opaque tokens to the outer
|
|
62
|
+
# shell — a model-supplied pattern (e.g. +"x; rm -rf ~"+) can't inject
|
|
63
|
+
# commands (assumes a POSIX +sh+ on the remote, which the shell contract
|
|
64
|
+
# already implies).
|
|
65
|
+
def glob(pattern)
|
|
66
|
+
script = 'for f in $1; do [ -e "$f" ] && echo "$f"; done'
|
|
67
|
+
command = "sh -c #{Shellwords.escape(script)} sh #{Shellwords.escape(pattern)}"
|
|
68
|
+
@client.exec(command)[:stdout].to_s.split("\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Releases the remote session via the client.
|
|
72
|
+
def close
|
|
73
|
+
@client.close
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
module Sandboxes
|
|
5
|
+
# In-memory sandbox with zero host access — the safe default. Files live in a
|
|
6
|
+
# Hash keyed by normalized absolute path, so nothing the model writes ever
|
|
7
|
+
# touches the host filesystem.
|
|
8
|
+
#
|
|
9
|
+
# +#shell+ raises +NotImplementedError+ on purpose: in-memory means there is
|
|
10
|
+
# no process to run a command in. That is the safety property, not a gap.
|
|
11
|
+
class Virtual < Sandbox
|
|
12
|
+
# Starts an empty in-memory filesystem rooted at +cwd+ (default +/workspace+).
|
|
13
|
+
def initialize(cwd: "/workspace")
|
|
14
|
+
@cwd = cwd
|
|
15
|
+
@files = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(path)
|
|
19
|
+
@files.fetch(norm(path)) { raise Errno::ENOENT, path }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(path, content)
|
|
23
|
+
@files[norm(path)] = content
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def glob(pattern)
|
|
27
|
+
normalized = norm(pattern)
|
|
28
|
+
@files.keys.select { |key| File.fnmatch(normalized, key) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def shell(_command, **)
|
|
32
|
+
raise NotImplementedError, "virtual sandbox has no shell"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def norm(path)
|
|
38
|
+
File.expand_path(path, @cwd)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nexo
|
|
4
|
+
# Namespace for the concrete sandbox tiers plus the single resolver shared by
|
|
5
|
+
# Agent and Workflow so the two can't drift (Spec 15). Reproduces the
|
|
6
|
+
# Agent's prior resolution *exactly*; the host +cwd:+ applies only to +:local+
|
|
7
|
+
# (a Sandboxes::Container keeps its own +/workspace+ default — the host dir
|
|
8
|
+
# enters a container only through a +binds:+ entry).
|
|
9
|
+
module Sandboxes
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Resolves a sandbox declaration (symbol / Hash / pre-built instance) into a
|
|
13
|
+
# concrete Sandbox. +cwd:+ is the host working directory used only by
|
|
14
|
+
# +:local+; container tiers ignore it. Raises ConfigurationError for an
|
|
15
|
+
# unknown value, and +image:+ stays required for containers (the
|
|
16
|
+
# Sandboxes::Container constructor raises when it is absent).
|
|
17
|
+
def resolve(value, cwd: Dir.pwd)
|
|
18
|
+
return value if value.is_a?(Nexo::Sandbox)
|
|
19
|
+
|
|
20
|
+
case value
|
|
21
|
+
when :virtual then Virtual.new
|
|
22
|
+
when :local then Local.new(cwd: cwd)
|
|
23
|
+
when :docker, :apple then Container.new(runtime: value) # image: required -> raises if absent
|
|
24
|
+
when Hash then resolve_hash(value, cwd)
|
|
25
|
+
else raise Nexo::ConfigurationError, "unknown sandbox: #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resolves the +{ type:, **opts }+ Hash form. +:docker+/+:apple+ pass every
|
|
30
|
+
# other key straight through to Sandboxes::Container (so an explicit Hash
|
|
31
|
+
# +cwd:+ or the +/workspace+ default wins — never the host +cwd+); +:local+
|
|
32
|
+
# honors an explicit Hash +cwd:+, otherwise falls back to the host +cwd+.
|
|
33
|
+
def resolve_hash(opts, cwd)
|
|
34
|
+
type = opts.fetch(:type)
|
|
35
|
+
rest = opts.except(:type)
|
|
36
|
+
case type
|
|
37
|
+
when :docker, :apple then Container.new(runtime: type, **rest)
|
|
38
|
+
when :local then Local.new(cwd: rest.fetch(:cwd, cwd))
|
|
39
|
+
else raise Nexo::ConfigurationError, "unknown sandbox type: #{type.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|