robot_lab 0.2.7 → 0.2.8

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +4 -0
  3. data/.loki +5 -0
  4. data/Archspec.rb +44 -0
  5. data/CHANGELOG.md +17 -1
  6. data/Rakefile +6 -111
  7. data/_typos.toml +21 -0
  8. data/docs/api/index.md +2 -2
  9. data/docs/api/skills.md +53 -149
  10. data/docs/api/support.md +1 -1
  11. data/docs/architecture/index.md +5 -4
  12. data/docs/getting-started/configuration.md +4 -1
  13. data/docs/guides/hooks.md +122 -0
  14. data/docs/guides/using-tools.md +10 -1
  15. data/lib/robot_lab/agent_skill_catalog.rb +1 -0
  16. data/lib/robot_lab/ask_user.rb +2 -0
  17. data/lib/robot_lab/bus_poller.rb +2 -0
  18. data/lib/robot_lab/capabilities.rb +4 -0
  19. data/lib/robot_lab/config.rb +8 -0
  20. data/lib/robot_lab/doom_loop_detector.rb +6 -3
  21. data/lib/robot_lab/history_compressor.rb +5 -0
  22. data/lib/robot_lab/hook.rb +1 -0
  23. data/lib/robot_lab/hook_context.rb +4 -0
  24. data/lib/robot_lab/hook_registry.rb +1 -0
  25. data/lib/robot_lab/hooks.rb +6 -3
  26. data/lib/robot_lab/mcp/client.rb +2 -2
  27. data/lib/robot_lab/mcp/connection_poller.rb +16 -8
  28. data/lib/robot_lab/mcp/server_discovery.rb +1 -0
  29. data/lib/robot_lab/mcp/transports/sse.rb +3 -0
  30. data/lib/robot_lab/mcp/transports/stdio.rb +5 -0
  31. data/lib/robot_lab/mcp/transports/streamable_http.rb +4 -0
  32. data/lib/robot_lab/mcp/transports/websocket.rb +3 -0
  33. data/lib/robot_lab/memory.rb +23 -6
  34. data/lib/robot_lab/memory_change.rb +1 -0
  35. data/lib/robot_lab/message.rb +3 -0
  36. data/lib/robot_lab/names.rb +2 -4
  37. data/lib/robot_lab/network.rb +13 -6
  38. data/lib/robot_lab/robot/agent_skill_matching.rb +2 -0
  39. data/lib/robot_lab/robot/bus_messaging.rb +3 -0
  40. data/lib/robot_lab/robot/history_search.rb +2 -0
  41. data/lib/robot_lab/robot/hooking.rb +3 -0
  42. data/lib/robot_lab/robot/mcp_management.rb +11 -3
  43. data/lib/robot_lab/robot/template_rendering.rb +13 -4
  44. data/lib/robot_lab/robot.rb +58 -17
  45. data/lib/robot_lab/robot_result.rb +3 -0
  46. data/lib/robot_lab/run_config.rb +5 -0
  47. data/lib/robot_lab/script_tool.rb +20 -39
  48. data/lib/robot_lab/state_proxy.rb +1 -0
  49. data/lib/robot_lab/streaming/context.rb +1 -0
  50. data/lib/robot_lab/streaming/events.rb +1 -0
  51. data/lib/robot_lab/task.rb +2 -0
  52. data/lib/robot_lab/tool.rb +4 -0
  53. data/lib/robot_lab/user_message.rb +1 -0
  54. data/lib/robot_lab/utils.rb +2 -0
  55. data/lib/robot_lab/version.rb +1 -1
  56. data/lib/robot_lab/waiter.rb +3 -0
  57. data/lib/robot_lab.rb +6 -3
  58. metadata +7 -7
  59. data/lib/robot_lab/sandbox/null.rb +0 -13
  60. data/lib/robot_lab/sandbox/seatbelt.rb +0 -104
  61. data/lib/robot_lab/sandbox.rb +0 -52
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36b770d963e004a951cdf35d07a2e867e4e661a9e342e8047184cfd9dafd6e15
4
- data.tar.gz: f53bb5c52efcd6169bf36afc69306a9c03e9cfcabdaf5b0a94871802e2909f55
3
+ metadata.gz: de87b78206bc424703b465d3a1f236db723ea38fe4235c07413466ab5b469ead
4
+ data.tar.gz: 6a7db14e4530e615e258415491422f297567a2f640983035f1542d88e7d200e5
5
5
  SHA512:
6
- metadata.gz: 850bde0e9ac1b7d65176bd067f42751d6d9db64517ca665d4765837fc5493e204f9b333cac08019cb451962d4d95789f986dee2ff08e55ae5e6a0d3d034f4f50
7
- data.tar.gz: 97eb4dc66458a9a43f861e3f9c927eb6cfa63d33ddfff150b4181a45dc2c0db9570095f2e71d1182b76b4ee85e7086a6014b5536e805d9956f0860f9b4a03f50
6
+ metadata.gz: f6814c9a8b86b8e6d1df2adcc2d9b4ab84c176c67211424e8404e9d83c1e08ffd353587523e4c45efec295d0e47bbd731b0cdc6f3e62754cfd4f1ce2a33d7655
7
+ data.tar.gz: 37682af098203ae333cc83fa6e66a95f1883aae0bed8411524a05d6795bb47b4a99f231353fe3546b2fdae8a5c098d0f45b863e531edaf237aab33e4e1bc3859
data/.envrc CHANGED
@@ -1,2 +1,6 @@
1
+ # robot_lab_project/robot_lab/.envrc
2
+
3
+ source_up
4
+
1
5
  export RR=`pwd`
2
6
  export BUNDLE_GEMFILE=Gemfile
data/.loki CHANGED
@@ -18,6 +18,11 @@ class Tasks
18
18
  sh "ruby -Ilib:test \"#{path}\""
19
19
  end
20
20
 
21
+ desc "Check architecture boundaries with ArchSpec"
22
+ def archspec
23
+ sh "bundle exec archspec check"
24
+ end
25
+
21
26
  class DocsCommands < Tasks
22
27
  desc "Build YARD + MkDocs documentation"
23
28
  def build
data/Archspec.rb ADDED
@@ -0,0 +1,44 @@
1
+ component :robot, in: "lib/robot_lab/robot.rb"
2
+ component :robot_support, in: "lib/robot_lab/robot/**/*.rb"
3
+ component :network, in: ["lib/robot_lab/network.rb", "lib/robot_lab/bus_poller.rb"]
4
+ component :memory, in: ["lib/robot_lab/memory.rb", "lib/robot_lab/memory_change.rb"]
5
+ component :mcp, in: "lib/robot_lab/mcp/**/*.rb"
6
+ component :sandbox, in: ["lib/robot_lab/sandbox.rb", "lib/robot_lab/sandbox/**/*.rb"]
7
+ component :streaming, in: "lib/robot_lab/streaming/**/*.rb"
8
+ component :budget, in: "lib/robot_lab/budget/**/*.rb"
9
+ component :hooks, in: %w[
10
+ lib/robot_lab/hook.rb
11
+ lib/robot_lab/hook_context.rb
12
+ lib/robot_lab/hook_registry.rb
13
+ lib/robot_lab/hooks.rb
14
+ ]
15
+ component :tools, in: %w[
16
+ lib/robot_lab/tool.rb
17
+ lib/robot_lab/tool_config.rb
18
+ lib/robot_lab/tool_manifest.rb
19
+ lib/robot_lab/script_tool.rb
20
+ ]
21
+ component :config, in: ["lib/robot_lab/config.rb", "lib/robot_lab/run_config.rb"]
22
+
23
+ # NOTE: every file here reopens `module RobotLab`, so a `dependencies.forbid`
24
+ # rule keyed on components (e.g. `memory.cannot_use :robot`) treats a plain
25
+ # `RobotLab.config` call as "depends on every component" — every file
26
+ # "defines" the bare RobotLab constant. Naming the actual target constants
27
+ # instead of components sidesteps that ambiguity.
28
+
29
+ # Memory is a low-level primitive shared by Robot and Network; it must not
30
+ # depend back on the things that depend on it.
31
+ memory.cannot_reference_constants "RobotLab::Robot", "RobotLab::Network"
32
+
33
+ # MCP is an external-tool integration layer that Robot/Network consume; it
34
+ # must not reach back into the objects that use it.
35
+ mcp.cannot_reference_constants "RobotLab::Robot", "RobotLab::Network"
36
+
37
+ # Sandbox runs untrusted/generated code paths and should have the smallest
38
+ # possible surface area — no reaching into orchestration or memory internals.
39
+ sandbox.cannot_reference_constants "RobotLab::Robot", "RobotLab::Network", "RobotLab::Memory"
40
+
41
+ # Config/RunConfig sits at the bottom of the configuration cascade
42
+ # (RobotLab.config -> Network -> Robot -> template -> task -> runtime) and
43
+ # must not depend upward on the objects it configures.
44
+ config.cannot_reference_constants "RobotLab::Robot", "RobotLab::Network"
data/CHANGELOG.md CHANGED
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
- ## [Unreleased]
9
+ ## [0.2.8] - 2026-09-09
10
10
 
11
11
  ### Added
12
12
  - `BusMessaging#respond_to_tasks` and `#serve` — a member auto-answers inbound
@@ -16,6 +16,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
  without hand-wiring `on_message`.
17
17
  - `.loki` Asgard task file: `test`, `rubocop`, `rubocop_fix`, `flog`, `flay`, `quality`, `build`, `install`, `release`, `integration`, `docs`, and `examples` tasks via the Asgard task runner
18
18
 
19
+ ### Changed (BREAKING)
20
+ - **Sandboxing extracted to `robot_lab-sandbox`.** `RobotLab::Sandbox`,
21
+ `RobotLab::Sandbox::Seatbelt`, and `RobotLab::Sandbox::Null` no longer live
22
+ in core; they moved verbatim to the new `robot_lab-sandbox` gem. Core has no
23
+ execution limitations of its own: `ScriptTool.execute` now delegates to an
24
+ optional `ScriptTool.executor` (`nil` by default, in which case it runs the
25
+ unconfined `Open3.capture2e` path with no timeout exactly as before).
26
+ Applications that want confined skill-script execution must add
27
+ `gem "robot_lab-sandbox"` and `require "robot_lab/sandbox"`, which installs
28
+ itself as the executor; `config.sandbox.*` behavior is unchanged from there.
29
+ `RobotLab::Capabilities` (the declarative fs_read/fs_write/network/timeout/
30
+ trust manifest parsed from `SKILL.md`) stays in core since `AgentSkill` needs
31
+ it regardless of whether sandboxing is enforced.
32
+ - `ScriptTool.run_with_timeout` and `ScriptTool.terminate` moved to
33
+ `RobotLab::Sandbox::Executor` in `robot_lab-sandbox`.
34
+
19
35
  ### Fixed
20
36
  - `BusMessaging` now synchronizes its message counter and outbox with a mutex, so
21
37
  concurrent sends and poller-thread reply correlation can't clobber each other.
data/Rakefile CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
3
+ # Quality gates (quality, rubocop_check, flog_check, flay_check,
4
+ # archspec_check, ...), doc *serving*, and the gem lifecycle (build, install,
5
+ # release) live in asgard — see .loki and the shared dev/*.loki files it
6
+ # imports. This Rakefile keeps the tasks asgard delegates to (tests,
7
+ # integration, examples, docs:build).
5
8
 
6
- RUBOCOP_ENV = { "RUBOCOP_CACHE_ROOT" => "tmp/rubocop_cache" }.freeze
9
+ require "rake/testtask"
7
10
 
8
11
  Rake::TestTask.new(:test) do |t|
9
12
  t.libs << "test"
@@ -36,100 +39,6 @@ Rake::TestTask.new(:integration) do |t|
36
39
  t.ruby_opts << "-rtest_helper"
37
40
  end
38
41
 
39
- desc "Check code complexity with Flog (warn ≥20, fail ≥50)"
40
- task :flog_check do
41
- require 'flog'
42
-
43
- # Target to work toward; methods above this are warned but don't fail the gate.
44
- METHOD_WARN = 20.0
45
- # Current baseline floor — established from first run. Reduce incrementally.
46
- METHOD_FAIL = 50.0
47
-
48
- flogger = Flog.new(all: true)
49
- flogger.flog(*Dir.glob('lib/**/*.rb'))
50
-
51
- warnings = []
52
- failures = []
53
-
54
- flogger.each_by_score do |method, score|
55
- next if method.end_with?('#none') # skip file-level non-method code
56
- if score > METHOD_FAIL
57
- failures << "#{'%.1f' % score}: #{method}"
58
- elsif score > METHOD_WARN
59
- warnings << "#{'%.1f' % score}: #{method}"
60
- end
61
- end
62
-
63
- unless warnings.empty?
64
- puts "\nFlog warnings (#{METHOD_WARN}–#{METHOD_FAIL}) — target for future refactoring:"
65
- warnings.each { |v| puts " #{v}" }
66
- end
67
-
68
- if failures.empty?
69
- puts "\nFlog: no methods exceed the failure threshold (≥#{METHOD_FAIL})"
70
- else
71
- puts "\nFlog failures (≥#{METHOD_FAIL}) — must be refactored:"
72
- failures.each { |v| puts " #{v}" }
73
- abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{METHOD_FAIL}"
74
- end
75
- end
76
-
77
- desc "Check for structural code duplication with Flay (mass >= 50)"
78
- task :flay_check do
79
- require 'flay'
80
-
81
- mass_threshold = 50
82
-
83
- flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
84
- flay.process(*Dir.glob('lib/**/*.rb'))
85
- flay.analyze
86
-
87
- if flay.hashes.empty?
88
- puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
89
- else
90
- puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
91
- flay.report
92
- abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
93
- end
94
- end
95
-
96
- desc "Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay"
97
- task :quality do
98
- results = {}
99
-
100
- puts "\n#{'=' * 60}"
101
- puts "Quality Gate: Tests + Coverage"
102
- puts '=' * 60
103
- results[:tests] = system("bundle exec rake test") ? :pass : :fail
104
-
105
- puts "\n#{'=' * 60}"
106
- puts "Quality Gate: RuboCop"
107
- puts '=' * 60
108
- results[:rubocop] = system(RUBOCOP_ENV, "bundle exec rubocop") ? :pass : :fail
109
-
110
- puts "\n#{'=' * 60}"
111
- puts "Quality Gate: Flog Complexity"
112
- puts '=' * 60
113
- results[:flog] = system("bundle exec rake flog_check") ? :pass : :fail
114
-
115
- puts "\n#{'=' * 60}"
116
- puts "Quality Gate: Flay Duplication"
117
- puts '=' * 60
118
- results[:flay] = system("bundle exec rake flay_check") ? :pass : :fail
119
-
120
- puts "\n#{'=' * 60}"
121
- puts "Quality Summary"
122
- puts '=' * 60
123
- results.each do |gate, status|
124
- icon = status == :pass ? 'PASS' : 'FAIL'
125
- puts " [#{icon}] #{gate}"
126
- end
127
- puts '=' * 60
128
-
129
- abort "\nQuality gate failed" if results.values.any?(:fail)
130
- puts "\nAll quality gates passed."
131
- end
132
-
133
42
  namespace :examples do
134
43
  # Map of subdirectory-based demos to their entry point scripts
135
44
  SUBDIR_ENTRY_POINTS = {
@@ -277,22 +186,8 @@ namespace :docs do
277
186
  sh "yard doc"
278
187
  end
279
188
 
280
- namespace :yard do
281
- desc "Serve YARD documentation locally"
282
- task :serve do
283
- sh "yard server --reload"
284
- end
285
- end
286
-
287
189
  desc "Build MkDocs documentation"
288
190
  task :mkdocs do
289
191
  sh "mkdocs build"
290
192
  end
291
-
292
- namespace :mkdocs do
293
- desc "Serve MkDocs documentation locally"
294
- task :serve do
295
- sh "mkdocs serve"
296
- end
297
- end
298
193
  end
data/_typos.toml ADDED
@@ -0,0 +1,21 @@
1
+ # False-positive suppressions for the typos quality gate (asgard).
2
+ # Everything here was reviewed by hand — see git history for context.
3
+
4
+ [files]
5
+ extend-exclude = [
6
+ # Example transcripts / articles: prose quotes real names ("Damon Clark")
7
+ # and SQL-ish plurals ("SELECTs") that typos mis-parses.
8
+ "examples/ruboruby.md",
9
+ "examples/25_history_search/conversation.jsonl",
10
+ ]
11
+
12
+ [default.extend-identifiers]
13
+ # Robot names in lib/robot_lab/names.rb
14
+ BATs = "BATs"
15
+ Damon = "Damon"
16
+ Mata_Nui = "Mata_Nui"
17
+ Pris = "Pris"
18
+ S1MONE = "S1MONE"
19
+ # Test-local variable names
20
+ ba = "ba" # b→a similarity (convergence_test.rb)
21
+ thr = "thr" # thread handle (connection_poller_test.rb)
data/docs/api/index.md CHANGED
@@ -41,8 +41,8 @@ confines them:
41
41
  | [AgentSkill](skills.md#robotlabagentskill) | One skill bundle: instructions + `scripts/` |
42
42
  | [AgentSkillCatalog](skills.md#robotlabagentskillcatalog) | Lazy registry over `~/.prompts/skills/` |
43
43
  | [Capabilities](skills.md#robotlabcapabilities) | Declared vs. ceiling filesystem / network / timeout grant |
44
- | [ScriptTool](skills.md#robotlabscripttool) | Wraps an executable script as a `Tool` |
45
- | [Sandbox](skills.md#robotlabsandbox) | macOS Seatbelt confinement, or a passthrough |
44
+ | [ScriptTool](skills.md#robotlabscripttool) | Wraps an executable script as a `Tool`; `ScriptTool.executor` is the confinement extension point |
45
+ | [Sandbox](skills.md#confinement-robot_lab-sandbox) | macOS Seatbelt confinement, or a passthrough — ships in the optional `robot_lab-sandbox` gem |
46
46
 
47
47
  ## Support Classes
48
48
 
data/docs/api/skills.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # Skills API
2
2
 
3
3
  Class-level reference for the AgentSkills subsystem: skill bundles, the scripts
4
- they expose as tools, the capabilities those scripts declare, and the sandbox
5
- that confines them. For the how-to, see
4
+ they expose as tools, and the capabilities those scripts declare. Core itself
5
+ has **no sandboxing and no execution limitations** — every script runs
6
+ unconfined until the optional
7
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem is
8
+ required, at which point it installs a confinement strategy (see
9
+ [ScriptTool.executor](#scripttoolexecutor) below). For the how-to, see
6
10
  [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
7
11
  and [Building Robots: Composable Skills](../guides/building-robots.md#composable-skills).
8
12
 
@@ -27,12 +31,16 @@ graph LR
27
31
  G[effective grant<br/>declared ∩ ceiling]
28
32
  end
29
33
 
30
- subgraph "Execution"
34
+ subgraph "Execution (core)"
31
35
  T[ScriptTool.from_path<br/>-> RobotLab::Tool]
36
+ EX{ScriptTool.executor<br/>set?}
37
+ UN[Open3.capture2e<br/>unconfined, no timeout]
38
+ end
39
+
40
+ subgraph "robot_lab-sandbox (optional gem)"
32
41
  SB{Sandbox.enabled?}
33
42
  SE[Sandbox::Seatbelt<br/>macOS]
34
43
  NU[Sandbox::Null<br/>passthrough]
35
- P[script process]
36
44
  end
37
45
 
38
46
  R[Robot<br/>AgentSkillMatching] --> C
@@ -41,12 +49,12 @@ graph LR
41
49
  S --> T
42
50
  D --> G
43
51
  CE --> G
44
- T --> SB
52
+ T --> EX
53
+ EX -- "nil (default)" --> UN
54
+ EX -- "installed" --> SB
45
55
  SB -- "off, or trust: core,<br/>or non-macOS" --> NU
46
56
  SB -- "on + macOS" --> SE
47
57
  G --> SE
48
- SE --> P
49
- NU --> P
50
58
  ```
51
59
 
52
60
  ---
@@ -264,9 +272,9 @@ of the ceiling, and `intersect` keeps the declared value.
264
272
  capabilities.core? # => trust == "core"
265
273
  ```
266
274
 
267
- A `core` skill is exempt from confinement: `Sandbox.for` returns a
268
- [`Null`](#robotlabsandboxnull) strategy for it regardless of platform or config.
269
- Reserve `trust: core` for bundles you wrote and audited.
275
+ A `core` skill is exempt from confinement: when `robot_lab-sandbox` is loaded,
276
+ `Sandbox.for` returns a `Null` strategy for it regardless of platform or
277
+ config. Reserve `trust: core` for bundles you wrote and audited.
270
278
 
271
279
  ### intersect
272
280
 
@@ -314,43 +322,32 @@ The generated tool takes a single optional `args` string parameter, which is
314
322
  [`derive_name`](#scripttoolderive_name) and its description from
315
323
  [`extract_description`](#scripttoolextract_description).
316
324
 
317
- ### ScriptTool.execute
325
+ ### ScriptTool.executor
318
326
 
319
327
  ```ruby
320
- RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
328
+ RobotLab::ScriptTool.executor # => #call, or nil (the default)
329
+ RobotLab::ScriptTool.executor = obj # any object responding to
330
+ # call(cmd, capabilities:, skill_dir:)
321
331
  ```
322
332
 
323
- Run a command array and return its combined stdout+stderr, or an error string.
324
- Two paths:
325
-
326
- - **Sandboxing off** (the default) `Open3.capture2e`, unconfined, **no timeout**.
327
- - **Sandboxing on** — intersects `capabilities` with `Capabilities.ceiling`, wraps
328
- the command with the strategy from `Sandbox.for`, runs it under the grant's
329
- timeout, and cleans the strategy up in an `ensure`.
330
-
331
- The declared `timeout:` therefore only takes effect when sandboxing is enabled.
333
+ The extension point core exposes for confinement. `nil` by default core has
334
+ no sandboxing of its own. `robot_lab-sandbox`, when required, sets this to
335
+ `RobotLab::Sandbox::Executor`, which handles `capabilities`/timeout/cleanup
336
+ itself. See that gem's docs for what it does when installed.
332
337
 
333
- ### ScriptTool.run_with_timeout
338
+ ### ScriptTool.execute
334
339
 
335
340
  ```ruby
336
- RobotLab::ScriptTool.run_with_timeout(cmd, timeout)
337
- # => [String, Process::Status | nil]
341
+ RobotLab::ScriptTool.execute(cmd, capabilities:, skill_dir:) # => String
338
342
  ```
339
343
 
340
- Run `cmd` in its own process group (`pgroup: true`), reading combined output
341
- until `timeout` seconds elapse. On expiry it terminates the group and returns
342
- `["<partial output>\n[killed: exceeded <N>s]", nil]` — a `nil` status is the
343
- timeout signal.
344
-
345
- ### ScriptTool.terminate
346
-
347
- ```ruby
348
- RobotLab::ScriptTool.terminate(pid)
349
- ```
344
+ Run a command array and return its combined stdout+stderr, or an error string.
345
+ Two paths:
350
346
 
351
- `Process.kill('-TERM', ...)` against the process **group** of `pid`, so a script
352
- that spawned children takes them down with it. Swallows every error and returns
353
- `nil` a process that already exited is not an error.
347
+ - **`executor` is `nil`** (the default) `Open3.capture2e`, unconfined, **no timeout**.
348
+ - **`executor` is set** delegates entirely to `executor.call(cmd, capabilities:, skill_dir:)`.
349
+ Core no longer knows or cares what the executor does with `capabilities` or
350
+ how (or whether) it bounds execution time.
354
351
 
355
352
  ### ScriptTool.format_result
356
353
 
@@ -393,121 +390,27 @@ cannot be read.
393
390
 
394
391
  ---
395
392
 
396
- ## RobotLab::Sandbox
393
+ ## Confinement: robot_lab-sandbox
397
394
 
398
- Strategy selector for confining skill-script execution. Module functions.
399
-
400
- ### Sandbox.enabled?
401
-
402
- ```ruby
403
- RobotLab::Sandbox.enabled? # => Boolean
404
- RobotLab::Sandbox.enabled?(some_config)
405
- ```
406
-
407
- `true` only when the config responds to `sandbox`, that section exists, and
408
- `sandbox.enabled == true`. **Sandboxing is off by default** — see the
409
- [`sandbox:` config section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section).
410
-
411
- ### Sandbox.macos?
412
-
413
- ```ruby
414
- RobotLab::Sandbox.macos? # => RUBY_PLATFORM.include?("darwin")
415
- ```
416
-
417
- ### Sandbox.for
418
-
419
- ```ruby
420
- strategy = RobotLab::Sandbox.for(grant, skill_dir:, macos: macos?)
421
- # => Sandbox::Seatbelt or Sandbox::Null
422
- ```
423
-
424
- | Name | Type | Default | Description |
425
- |------|------|---------|-------------|
426
- | `grant` | `Capabilities` | **required** | The **already-intersected** effective grant |
427
- | `skill_dir` | `String` | **required** | Bundle root, always granted read access |
428
- | `macos` | `Boolean` | `macos?` | Injectable so both branches are testable on any host |
395
+ `RobotLab::Sandbox`, `RobotLab::Sandbox::Seatbelt`, and `RobotLab::Sandbox::Null`
396
+ used to live here; they now ship in the separate
397
+ [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox) gem, which
398
+ core has no dependency on. Requiring it installs `RobotLab::Sandbox::Executor`
399
+ as [`ScriptTool.executor`](#scripttoolexecutor):
429
400
 
430
- Selection order: a `trust: core` grant gets `Null`; otherwise macOS gets
431
- `Seatbelt`; anything else warns once and gets `Null`.
432
-
433
- ### Sandbox.warn_once_non_macos
434
-
435
- ```ruby
436
- RobotLab::Sandbox.warn_once_non_macos
437
- ```
438
-
439
- Emits `"Sandbox: OS-level confinement is only available on macOS; scripts run
440
- unconfined here"` at `warn`, at most once per process. Idempotent, so a run with
441
- many scripts does not flood the log.
442
-
443
- ---
444
-
445
- ## RobotLab::Sandbox::Null
446
-
447
- Passthrough strategy — used off macOS and for `trust: core` skills.
448
-
449
- | Method | Returns | Description |
450
- |--------|---------|-------------|
451
- | `wrap(cmd)` | `cmd` | Unchanged |
452
- | `cleanup` | `nil` | No-op |
453
-
454
- ---
455
-
456
- ## RobotLab::Sandbox::Seatbelt
457
-
458
- macOS strategy: generates a deny-by-default `sandbox-exec` profile from the grant
459
- and wraps the command as `sandbox-exec -f <profile> <cmd...>`.
460
-
461
- ### Constants
462
-
463
- | Constant | Description |
464
- |----------|-------------|
465
- | `SYSTEM_READ` | `/usr /bin /sbin /System /Library /opt /private/etc /dev /var/select` — the locations an interpreter needs to boot |
466
- | `DEV_WRITE` | `/dev/null /dev/stdout /dev/stderr /dev/dtracehelper /dev/tty` — always writable |
467
-
468
- ### Constructor
469
-
470
- ```ruby
471
- RobotLab::Sandbox::Seatbelt.new(grant, skill_dir:)
472
- ```
473
-
474
- ### wrap / cleanup
475
-
476
- ```ruby
477
- cmd = strategy.wrap(["bash", "script.sh"])
478
- # => ["sandbox-exec", "-f", "/tmp/robot_lab-sandbox-xxxx.sb", "bash", "script.sh"]
479
- strategy.cleanup # unlinks the generated profile
480
- ```
481
-
482
- `wrap` writes the profile to a `Tempfile`; `cleanup` unlinks it and swallows any
483
- error (an already-removed file is fine). `ScriptTool.execute` always calls
484
- `cleanup` in an `ensure`.
485
-
486
- ### profile_text
487
-
488
- ```ruby
489
- strategy.profile_text # => String
490
- ```
401
+ - `Sandbox.enabled?` reads `config.sandbox.enabled` (default `false`).
402
+ - `Sandbox.for(grant, skill_dir:, macos: macos?)` picks `Sandbox::Seatbelt` on
403
+ macOS, `Sandbox::Null` (passthrough) elsewhere or for `trust: core` grants.
404
+ - `Sandbox::Seatbelt` — generates a deny-by-default `sandbox-exec` profile from
405
+ the effective grant (`fs_read`/`fs_write`/`network`), wrapping the command as
406
+ `sandbox-exec -f <profile> <cmd...>`. `$HOME` is never implicitly readable.
407
+ - `Sandbox::Null` — `wrap(cmd)` returns `cmd` unchanged; `cleanup` is a no-op.
408
+ - `Sandbox::Executor` — the piece that plugs into core: intersects the skill's
409
+ `Capabilities` with `Capabilities.ceiling`, wraps and runs the command under
410
+ the chosen strategy, and bounds it with the grant's `timeout`
411
+ (`Process.kill('-TERM', ...)` on the process group on expiry).
491
412
 
492
- The generated Seatbelt profile. Public so the policy can be asserted in tests
493
- rather than inferred from behavior. It imports `bsd.sb` (without which a
494
- deny-default profile aborts the binary before it starts), denies by default, then
495
- allows: `process-fork`, `process-exec`, `sysctl-read`, `mach-lookup`,
496
- `file-read-metadata` on any path, `file-read*` on `SYSTEM_READ` + the skill
497
- directory + granted `fs_read` paths, `file-write*` on `DEV_WRITE` + granted
498
- `fs_write` paths, and `network*` only when the grant allows it.
499
-
500
- Every path is canonicalized to its symlink-free real path first, because macOS
501
- symlinks `/tmp` → `/private/tmp` and the kernel matches against the real path. For
502
- write targets that do not exist yet, the nearest existing ancestor is resolved and
503
- the remainder re-appended.
504
-
505
- !!! warning "`$HOME` is never implicitly readable"
506
- Which is the point — SSH keys and cloud credentials stay out of reach. But it
507
- also means an interpreter installed under `$HOME` (rbenv, asdf, mise, a
508
- Homebrew prefix in `~`) is **invisible** to the sandboxed process and the
509
- script fails to start. Grant that path explicitly in `fs_read`, or mark the
510
- skill `trust: core`.
413
+ Full reference lives in that gem's own docs.
511
414
 
512
415
  ---
513
416
 
@@ -515,5 +418,6 @@ the remainder re-appended.
515
418
 
516
419
  - [Using Tools: Skill Scripts and Sandboxing](../guides/using-tools.md#skill-scripts-and-sandboxing)
517
420
  - [Configuration: `sandbox:` section](../getting-started/configuration.md#skill-script-sandboxing-sandbox-section)
421
+ - [robot_lab-sandbox](https://github.com/MadBomber/robot_lab-sandbox) — the optional confinement gem
518
422
  - [Robot: Skills](core/robot.md#skills) — template skills, the other meaning
519
423
  - [Tool](core/tool.md) — `Tool.create`, which `ScriptTool` builds on
data/docs/api/support.md CHANGED
@@ -842,5 +842,5 @@ Thread-safe monotonic counter for event ordering.
842
842
 
843
843
  - [Core Classes](core/index.md) — `Robot`, `Network`, `Memory`, `Tool`, `RobotResult`
844
844
  - [Hooks API](hooks.md) — the extension seam these classes are wired into
845
- - [Skills API](skills.md) — `AgentSkill`, `Capabilities`, `ScriptTool`, `Sandbox`
845
+ - [Skills API](skills.md) — `AgentSkill`, `Capabilities`, `ScriptTool` (confinement lives in the optional `robot_lab-sandbox` gem)
846
846
  - [Errors](errors.md) — including `Errors.retryable?` and `Errors.retryable_classes`
@@ -62,7 +62,7 @@ graph TB
62
62
  H[MCP Client]
63
63
  I[Tools &lt; RubyLLM::Tool]
64
64
  J[Templates / prompt_manager]
65
- SK[AgentSkills + Sandbox]
65
+ SK[AgentSkills<br/>+ optional Sandbox]
66
66
  end
67
67
 
68
68
  subgraph "Execution Layer"
@@ -138,8 +138,9 @@ graph LR
138
138
  AS --> CAT[AgentSkillCatalog]
139
139
  AS --> CAP[Capabilities]
140
140
  AS --> ST[ScriptTool]
141
- CAP --> SB[Sandbox<br/>Seatbelt or Null]
142
- ST --> SB
141
+ ST --> EX{ScriptTool.executor}
142
+ EX -. "installed by<br/>robot_lab-sandbox" .-> SB[Sandbox<br/>Seatbelt or Null]
143
+ CAP -. "used if installed" .-> SB
143
144
 
144
145
  HC --> TA[TextAnalysis<br/>TF / TF-IDF]
145
146
  HS --> TA
@@ -165,7 +166,7 @@ dependency.
165
166
  | **Config** | MywayConfig-based global configuration with env var and file support | [Configuration](#configuration) |
166
167
  | **Hook** | Handler base class for the seven hook families — the framework's extension seam | [Hooks API](../api/hooks.md) |
167
168
  | **AgentSkill** | A `SKILL.md` bundle whose instructions and `scripts/` become prompt text and tools | [Skills API](../api/skills.md) |
168
- | **Sandbox** | Opt-in OS-level confinement (macOS Seatbelt) for skill scripts, derived from `Capabilities` | [Skills API](../api/skills.md) |
169
+ | **Sandbox** | Opt-in OS-level confinement (macOS Seatbelt) for skill scripts, derived from `Capabilities`; ships in the optional `robot_lab-sandbox` gem, not core | [Skills API](../api/skills.md) |
169
170
  | **RobotMessage** | Immutable envelope for TypedBus inter-robot messaging, serialized per robot by `BusPoller` | [Support API](../api/support.md) |
170
171
  | **Budget::Ledger** | Thread-safe reserve/reconcile ledger behind `token_budget` / `cost_budget` | [Support API](../api/support.md) |
171
172
 
@@ -278,7 +278,10 @@ robot = RobotLab.build(
278
278
 
279
279
  ### Skill-Script Sandboxing (`sandbox:` section)
280
280
 
281
- Opt-in confinement for the scripts a [skill bundle](../guides/using-tools.md#skill-scripts-and-sandboxing) exposes as tools. Disabled by default scripts run exactly as before until you turn it on:
281
+ Core declares this config schema but has no sandboxing behavior of its ownenforcement
282
+ lives in the optional [`robot_lab-sandbox`](https://github.com/MadBomber/robot_lab-sandbox)
283
+ gem. Without it, skill scripts always run unconfined regardless of these values; requiring
284
+ it installs a `RobotLab::ScriptTool.executor` that honors them:
282
285
 
283
286
  | Key | Default | Description |
284
287
  |-----|---------|-------------|