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.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/.document +3 -0
  3. data/CHANGELOG.md +232 -0
  4. data/README.md +75 -4
  5. data/Rakefile +29 -0
  6. data/app/views/nexo/_event.html.erb +1 -0
  7. data/docs/concurrency.md +94 -0
  8. data/docs/durable-workflows.md +184 -0
  9. data/docs/getting-started.md +103 -0
  10. data/docs/loops.md +93 -0
  11. data/docs/mcp.md +144 -0
  12. data/docs/permissions.md +47 -0
  13. data/docs/rails.md +129 -0
  14. data/docs/sandboxes.md +290 -0
  15. data/docs/sessions.md +99 -0
  16. data/docs/skills.md +68 -0
  17. data/docs/tools.md +38 -0
  18. data/docs/web.md +130 -0
  19. data/docs/workflows.md +255 -0
  20. data/examples/README.md +51 -0
  21. data/examples/approval_agent.rb +64 -0
  22. data/examples/approval_workflow.rb +62 -0
  23. data/examples/artifact_from_template.rb +54 -0
  24. data/examples/chat_session.rb +49 -0
  25. data/examples/code_reviewer.rb +64 -0
  26. data/examples/container_review.rb +52 -0
  27. data/examples/inbox_digest.rb +62 -0
  28. data/examples/inbox_digest_http.rb +69 -0
  29. data/examples/inbox_digest_task.rb +40 -0
  30. data/examples/mcp_filesystem.rb +50 -0
  31. data/examples/news_search.rb +49 -0
  32. data/examples/news_summary.rb +37 -0
  33. data/examples/rails_usage.md +141 -0
  34. data/examples/skills/email_triage/SKILL.md +47 -0
  35. data/examples/skills/news_summary/SKILL.md +37 -0
  36. data/examples/skills/ruby-code-review/SKILL.md +61 -0
  37. data/lib/generators/nexo/artifacts/artifacts_generator.rb +37 -0
  38. data/lib/generators/nexo/artifacts/templates/add_artifacts_to_nexo_workflow_runs.rb +11 -0
  39. data/lib/generators/nexo/install/install_generator.rb +35 -0
  40. data/lib/generators/nexo/install/templates/nexo.rb +19 -0
  41. data/lib/generators/nexo/skill/skill_generator.rb +45 -0
  42. data/lib/generators/nexo/skill/templates/SKILL.md.tt +10 -0
  43. data/lib/generators/nexo/state/state_generator.rb +37 -0
  44. data/lib/generators/nexo/state/templates/add_state_to_nexo_workflow_runs.rb +13 -0
  45. data/lib/generators/nexo/workflows/templates/create_nexo_workflow_runs.rb +26 -0
  46. data/lib/generators/nexo/workflows/workflows_generator.rb +34 -0
  47. data/lib/nexo/agent.rb +423 -0
  48. data/lib/nexo/concurrent.rb +78 -0
  49. data/lib/nexo/configuration.rb +82 -0
  50. data/lib/nexo/engine.rb +51 -0
  51. data/lib/nexo/loop.rb +30 -0
  52. data/lib/nexo/loops/agent_sdk.rb +68 -0
  53. data/lib/nexo/loops/ruby_llm.rb +66 -0
  54. data/lib/nexo/mcp/gated_tool.rb +70 -0
  55. data/lib/nexo/mcp.rb +96 -0
  56. data/lib/nexo/output_truncator.rb +41 -0
  57. data/lib/nexo/permissions.rb +162 -0
  58. data/lib/nexo/read_tracker.rb +32 -0
  59. data/lib/nexo/run_store.rb +162 -0
  60. data/lib/nexo/sandbox.rb +64 -0
  61. data/lib/nexo/sandboxes/container.rb +293 -0
  62. data/lib/nexo/sandboxes/local.rb +157 -0
  63. data/lib/nexo/sandboxes/remote.rb +77 -0
  64. data/lib/nexo/sandboxes/virtual.rb +42 -0
  65. data/lib/nexo/sandboxes.rb +43 -0
  66. data/lib/nexo/session.rb +152 -0
  67. data/lib/nexo/skills.rb +54 -0
  68. data/lib/nexo/tools/fetch.rb +133 -0
  69. data/lib/nexo/tools/glob.rb +28 -0
  70. data/lib/nexo/tools/read_file.rb +54 -0
  71. data/lib/nexo/tools/shell.rb +35 -0
  72. data/lib/nexo/tools/web_search.rb +81 -0
  73. data/lib/nexo/tools/write_file.rb +61 -0
  74. data/lib/nexo/turbo_broadcaster.rb +41 -0
  75. data/lib/nexo/version.rb +2 -1
  76. data/lib/nexo/workflow.rb +705 -0
  77. data/lib/nexo/workflow_job.rb +42 -0
  78. data/lib/nexo/workflow_run.rb +128 -0
  79. data/lib/nexo.rb +138 -1
  80. data/lib/tasks/nexo.rake +17 -0
  81. data/sig/nexo/agent.rbs +23 -0
  82. data/sig/nexo/output_truncator.rbs +7 -0
  83. data/sig/nexo/permissions.rbs +15 -0
  84. data/sig/nexo/read_tracker.rbs +8 -0
  85. data/sig/nexo/sandbox.rbs +12 -0
  86. data/sig/nexo/sandboxes/container.rbs +26 -0
  87. data/sig/nexo/sandboxes/local.rbs +12 -0
  88. data/sig/nexo/sandboxes/virtual.rbs +7 -0
  89. data/sig/nexo/sandboxes.rbs +5 -0
  90. data/sig/nexo/tools.rbs +28 -0
  91. data/sig/nexo_ai.rbs +22 -1
  92. 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