robot_lab-sandbox 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.
@@ -0,0 +1,121 @@
1
+ # Getting Started
2
+
3
+ ## Prerequisites
4
+
5
+ - Ruby 3.2+ (per the gemspec's `required_ruby_version`)
6
+ - `robot_lab` — `robot_lab/sandbox` requires `RobotLab::ScriptTool` to already
7
+ be defined, so `robot_lab` must be `require`d first.
8
+ - macOS, if you want real OS-level confinement. On other platforms the gem
9
+ still loads and wires itself in, but every script runs through
10
+ `Sandbox::Null` (a passthrough) with a one-time warning logged.
11
+
12
+ ## Installation
13
+
14
+ Add to your `Gemfile`:
15
+
16
+ ```ruby
17
+ gem "robot_lab"
18
+ gem "robot_lab-sandbox"
19
+ ```
20
+
21
+ Then:
22
+
23
+ ```sh
24
+ bundle install
25
+ ```
26
+
27
+ Or install directly:
28
+
29
+ ```sh
30
+ gem install robot_lab-sandbox
31
+ ```
32
+
33
+ ## Enabling
34
+
35
+ Requiring the gem and turning on confinement are two separate steps —
36
+ matching how `robot_lab` core's `sandbox:` config section already worked
37
+ before this gem existed.
38
+
39
+ **1. Require it**, after `robot_lab`:
40
+
41
+ ```ruby
42
+ require "robot_lab"
43
+ require "robot_lab/sandbox"
44
+ ```
45
+
46
+ This installs `RobotLab::Sandbox::Executor` as `RobotLab::ScriptTool.executor`
47
+ and registers the gem via `RobotLab.register_extension(:sandbox, ...)`. On its
48
+ own this changes nothing yet — see [How It Works](how_it_works.md#scripttoolexecute-with-no-executor-installed)
49
+ for why: `ScriptTool.execute` only delegates to the executor when one is
50
+ installed, and the executor itself checks `Sandbox.enabled?` before doing
51
+ anything.
52
+
53
+ **2. Turn on confinement** in config:
54
+
55
+ ```yaml
56
+ # config/robot_lab.yml
57
+ sandbox:
58
+ enabled: true
59
+ fs_read: ["."]
60
+ fs_write: []
61
+ network: false
62
+ timeout: 60
63
+ ```
64
+
65
+ or via environment variables:
66
+
67
+ ```sh
68
+ export ROBOT_LAB_SANDBOX__ENABLED=true
69
+ ```
70
+
71
+ If `robot_lab` has not been loaded yet, `require "robot_lab/sandbox"` raises
72
+ `RobotLab::Sandbox::Error` ("robot_lab must be loaded before robot_lab/sandbox").
73
+
74
+ ## Minimal Example
75
+
76
+ ```ruby
77
+ require "robot_lab"
78
+ require "robot_lab/sandbox"
79
+
80
+ RobotLab.config.sandbox.enabled = true
81
+ RobotLab.config.sandbox.fs_write = ["./tmp"]
82
+
83
+ # A skill's SKILL.md declares what it wants; robot_lab core builds the
84
+ # Capabilities object from that front matter automatically when the skill
85
+ # is discovered. Here we build one directly to call ScriptTool ourselves:
86
+ capabilities = RobotLab::Capabilities.new(fs_write: ["./tmp"], timeout: 10)
87
+
88
+ tool = RobotLab::ScriptTool.from_path(
89
+ "./skills/cleanup/scripts/purge_tmp.sh",
90
+ capabilities: capabilities,
91
+ skill_dir: "./skills/cleanup"
92
+ )
93
+
94
+ puts tool.call({}) # runs confined on macOS, unconfined (with a warning) elsewhere
95
+ ```
96
+
97
+ In practice you won't call `ScriptTool.from_path` directly — a discovered
98
+ `AgentSkill` builds its own `script_tools` from its `SKILL.md` front matter
99
+ and capabilities automatically. This example exists to show the pieces in
100
+ isolation; see [robot_lab's Skill Scripts and Sandboxing guide](https://github.com/MadBomber/robot_lab/blob/main/docs/guides/using-tools.md#skill-scripts-and-sandboxing)
101
+ for the end-to-end skill-bundle path.
102
+
103
+ ## Checking What's Installed
104
+
105
+ ```ruby
106
+ RobotLab.extension_loaded?(:sandbox) # => true, once required
107
+ RobotLab::ScriptTool.executor # => RobotLab::Sandbox::Executor, or nil
108
+ RobotLab::Sandbox.enabled? # => reads config.sandbox.enabled
109
+ RobotLab::Sandbox.macos? # => RUBY_PLATFORM.include?("darwin")
110
+ ```
111
+
112
+ ## Key Constraints
113
+
114
+ - `robot_lab` must be `require`d before `robot_lab/sandbox` — see Prerequisites above.
115
+ - Confinement is opt-in via `config.sandbox.enabled` (default `false`).
116
+ Requiring this gem does **not** turn confinement on by itself.
117
+ - Real OS-level confinement is macOS-only. Off macOS, or for any skill
118
+ declaring `trust: core`, every script runs through the `Sandbox::Null`
119
+ passthrough regardless of `sandbox.enabled`.
120
+ - The declared `timeout:` (per-skill or the config ceiling) only takes effect
121
+ once this gem is loaded — core alone runs scripts with no timeout at all.
@@ -0,0 +1,236 @@
1
+ # How It Works
2
+
3
+ ## The Extension Point: `ScriptTool.executor`
4
+
5
+ `robot_lab` core's `RobotLab::ScriptTool` exposes a single seam for
6
+ confinement:
7
+
8
+ ```ruby
9
+ module RobotLab
10
+ module ScriptTool
11
+ class << self
12
+ attr_accessor :executor
13
+ end
14
+ end
15
+ end
16
+ ```
17
+
18
+ `executor` is `nil` by default. `ScriptTool.execute(cmd, capabilities:, skill_dir:)`
19
+ checks it on every call:
20
+
21
+ ```ruby
22
+ def self.execute(cmd, capabilities:, skill_dir:)
23
+ return executor.call(cmd, capabilities: capabilities, skill_dir: skill_dir) if executor
24
+
25
+ output, status = Open3.capture2e(*cmd)
26
+ format_result(output, status)
27
+ end
28
+ ```
29
+
30
+ ### `ScriptTool.execute` with no executor installed
31
+
32
+ Without `robot_lab-sandbox` (or any other gem setting `executor`), every call
33
+ takes the second branch: a plain `Open3.capture2e`, unconfined, with no
34
+ timeout. This is exactly how core alone always behaved — the extension point
35
+ was added specifically so this default stays true with zero code in core
36
+ that knows anything about sandboxing.
37
+
38
+ ### What loading this gem does
39
+
40
+ `lib/robot_lab/sandbox.rb` runs this at load time:
41
+
42
+ ```ruby
43
+ unless defined?(RobotLab::ScriptTool)
44
+ raise RobotLab::Sandbox::Error, "robot_lab must be loaded before robot_lab/sandbox"
45
+ end
46
+
47
+ RobotLab::ScriptTool.executor = RobotLab::Sandbox::Executor
48
+
49
+ if RobotLab.respond_to?(:register_extension)
50
+ RobotLab.register_extension(:sandbox, RobotLab::Sandbox)
51
+ end
52
+ ```
53
+
54
+ From that point on, every `ScriptTool.execute` call delegates entirely to
55
+ `RobotLab::Sandbox::Executor.call`. Core no longer knows or cares what the
56
+ executor does with `capabilities` or how — or whether — it bounds execution
57
+ time; that's this gem's job from here down.
58
+
59
+ ## `Sandbox::Executor` — What Actually Runs
60
+
61
+ ```ruby
62
+ def self.call(cmd, capabilities:, skill_dir:)
63
+ unless Sandbox.enabled?
64
+ output, status = Open3.capture2e(*cmd)
65
+ return RobotLab::ScriptTool.format_result(output, status)
66
+ end
67
+
68
+ grant = capabilities.intersect(Capabilities.ceiling)
69
+ sandbox = Sandbox.for(grant, skill_dir: skill_dir)
70
+ begin
71
+ output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
72
+ RobotLab::ScriptTool.format_result(output, status)
73
+ ensure
74
+ sandbox.cleanup
75
+ end
76
+ end
77
+ ```
78
+
79
+ Two paths, gated by `Sandbox.enabled?` (which reads `config.sandbox.enabled`,
80
+ default `false`):
81
+
82
+ 1. **Disabled** — identical to core's own unconfined path. Loading this gem
83
+ with `sandbox.enabled: false` changes nothing observable.
84
+ 2. **Enabled** — computes the effective grant (skill's declared `Capabilities`
85
+ ∩ the config ceiling, see [Configuration](configuration.md#the-intersection)),
86
+ picks a strategy via `Sandbox.for`, wraps the command, runs it under a
87
+ timeout, and always calls `sandbox.cleanup` in an `ensure` — even if the
88
+ run raised or timed out.
89
+
90
+ ## Strategy Selection: `Sandbox.for`
91
+
92
+ ```ruby
93
+ def self.for(grant, skill_dir:, macos: macos?)
94
+ return Null.new if grant.core?
95
+ return Seatbelt.new(grant, skill_dir: skill_dir) if macos
96
+
97
+ warn_once_non_macos
98
+ Null.new
99
+ end
100
+ ```
101
+
102
+ Selection order:
103
+
104
+ 1. `trust: core` (`grant.core?`) → always `Sandbox::Null`, on every platform.
105
+ 2. macOS → `Sandbox::Seatbelt`.
106
+ 3. Anything else → `Sandbox::Null`, plus a one-time `warn`-level log message
107
+ (`Sandbox.warn_once_non_macos`, idempotent — a run with many scripts
108
+ doesn't flood the log).
109
+
110
+ `macos:` is injectable (defaults to the real `RUBY_PLATFORM` check) so both
111
+ branches are testable on any host without stubbing.
112
+
113
+ ## `Sandbox::Null` — the Passthrough
114
+
115
+ ```ruby
116
+ class Null
117
+ def wrap(cmd) = cmd
118
+ def cleanup; end
119
+ end
120
+ ```
121
+
122
+ `wrap` is the identity function; `cleanup` is a no-op. Used off-macOS and for
123
+ `trust: core` skills — the command runs exactly as it would with no executor
124
+ installed at all, except still bounded by `Executor`'s timeout.
125
+
126
+ ## `Sandbox::Seatbelt` — Real Confinement on macOS
127
+
128
+ `wrap(cmd)` generates a Seatbelt profile to a `Tempfile` and returns:
129
+
130
+ ```ruby
131
+ ["sandbox-exec", "-f", profile_path, *cmd]
132
+ ```
133
+
134
+ ### The generated profile
135
+
136
+ ```
137
+ (version 1)
138
+ (import "bsd.sb")
139
+ (deny default)
140
+ (allow process-fork)
141
+ (allow process-exec)
142
+ (allow sysctl-read)
143
+ (allow mach-lookup)
144
+ (allow file-read-metadata)
145
+ (allow file-read* (subpath "/usr") (subpath "/bin") ... (subpath <skill_dir>) (subpath <granted fs_read>) ...)
146
+ (allow file-write* (literal "/dev/null") ... (subpath <granted fs_write>))
147
+ (allow network*) ; only present when the grant allows network
148
+ ```
149
+
150
+ Built from three pieces:
151
+
152
+ | Piece | Source |
153
+ |-------|--------|
154
+ | **Deny by default** | `(deny default)` — nothing is allowed unless an explicit `(allow ...)` clause says so. |
155
+ | **Boot allowances** | `(import "bsd.sb")` (the base rules a process needs to start — dyld, mach bootstrap, etc.; without it a deny-default profile aborts the binary before it runs), plus `process-fork`, `process-exec`, `sysctl-read`, `mach-lookup`, and `file-read-metadata` on any path (so the interpreter can `stat`/traverse to reach granted files — reading file *contents* stays restricted separately). |
156
+ | **The grant** | `file-read*` on `Seatbelt::SYSTEM_READ` (`/usr /bin /sbin /System /Library /opt /private/etc /dev /var/select`) plus the skill directory plus the grant's `fs_read`; `file-write*` on `Seatbelt::DEV_WRITE` (`/dev/null /dev/stdout /dev/stderr /dev/dtracehelper /dev/tty`) plus the grant's `fs_write`; `network*` only when `grant.network` is true. |
157
+
158
+ `$HOME` is never in any of these lists — it is never implicitly readable.
159
+ See [Troubleshooting](troubleshooting.md#interpreter-installed-under-home-is-invisible)
160
+ for the direct consequence of that.
161
+
162
+ ### Path canonicalization
163
+
164
+ Every path is resolved to its symlink-free real path before being written
165
+ into the profile:
166
+
167
+ ```ruby
168
+ def canonicalize(paths)
169
+ Array(paths).map { |p| real_path(File.expand_path(p.to_s)) }.compact.uniq
170
+ end
171
+ ```
172
+
173
+ This matters because macOS symlinks `/tmp` → `/private/tmp`, `/var` →
174
+ `/private/var`, etc. — the kernel matches Seatbelt rules against the *real*
175
+ path, so a rule written against the logical `/tmp/...` path would silently
176
+ never match. For a write target that doesn't exist yet, `real_path` walks up
177
+ to the nearest existing ancestor, resolves *that*, and re-appends the
178
+ non-existent remainder.
179
+
180
+ ### Lifecycle
181
+
182
+ `wrap` writes the profile via `Tempfile.create`; `cleanup` unlinks it and
183
+ swallows any error (an already-removed file is fine). `Executor.call` always
184
+ calls `sandbox.cleanup` in an `ensure`, so a profile file is never leaked
185
+ even when the script raises or times out.
186
+
187
+ ## Timeout Enforcement
188
+
189
+ `Executor.run_with_timeout(cmd, timeout)` runs the (possibly Seatbelt-wrapped)
190
+ command in its own process group and reads combined stdout+stderr until
191
+ `timeout` seconds elapse:
192
+
193
+ ```ruby
194
+ def self.run_with_timeout(cmd, timeout)
195
+ Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
196
+ stdin.close
197
+ output = +""
198
+ begin
199
+ Timeout.timeout(timeout) { output << out.read }
200
+ rescue Timeout::Error
201
+ terminate(wait.pid)
202
+ return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
203
+ end
204
+ [output, wait.value]
205
+ end
206
+ end
207
+ ```
208
+
209
+ On expiry, `terminate` sends `SIGTERM` to the whole process **group**
210
+ (`Process.kill('-TERM', ...)` against `Process.getpgid(pid)`), so a script
211
+ that spawned children takes them down with it, and swallows any error — a
212
+ process that already exited by the time `terminate` runs is not an error. A
213
+ `nil` status return value is `ScriptTool.format_result`'s signal for
214
+ `"Error (timed out):\n<output>"`, which is what the LLM sees; a `nil`
215
+ status is never confused with a real (even nonzero) exit code.
216
+
217
+ The timeout used is `grant.timeout` — the smaller of the skill's declared
218
+ `timeout:` and the config ceiling's `sandbox.timeout` (see
219
+ [Configuration](configuration.md#the-intersection)). This bound applies only
220
+ because this gem is loaded; core alone never times a script out.
221
+
222
+ ## Extension Registration
223
+
224
+ `RobotLab.register_extension(:sandbox, RobotLab::Sandbox)` lets other code
225
+ detect this gem is loaded without depending on it directly:
226
+
227
+ ```ruby
228
+ RobotLab.extension_loaded?(:sandbox) # => true, once required
229
+ RobotLab.extension(:sandbox) # => RobotLab::Sandbox, or nil
230
+ ```
231
+
232
+ This follows the same pattern every other RobotLab extension gem
233
+ (`robot_lab-audit`, `robot_lab-durable`, etc.) uses — `register_extension` is
234
+ a no-op-safe check (`RobotLab.respond_to?(:register_extension)`), so
235
+ `robot_lab/sandbox` never raises on this line even against an unusually old
236
+ `robot_lab` core.
data/docs/index.md ADDED
@@ -0,0 +1,58 @@
1
+ # robot_lab-sandbox
2
+
3
+ OS-level confinement for [RobotLab](https://github.com/MadBomber/robot_lab) skill scripts.
4
+
5
+ `robot_lab` core has **no sandboxing and no execution limitations of its own** —
6
+ every [AgentSkill](https://github.com/MadBomber/robot_lab/blob/main/docs/api/skills.md)
7
+ script runs as a plain, unconfined OS process. `robot_lab-sandbox` plugs into
8
+ core's one extension point for this (`RobotLab::ScriptTool.executor`) and, once
9
+ turned on in config, confines each script to a declared, ceiling-clamped set of
10
+ capabilities: which paths it may read and write, whether it may reach the
11
+ network, and how long it may run.
12
+
13
+ ```ruby
14
+ require "robot_lab"
15
+ require "robot_lab/sandbox"
16
+ # RobotLab::ScriptTool.executor is now RobotLab::Sandbox::Executor.
17
+ ```
18
+
19
+ ```yaml
20
+ # config/robot_lab.yml
21
+ sandbox:
22
+ enabled: true
23
+ fs_read: ["."]
24
+ fs_write: ["./tmp"]
25
+ network: false
26
+ timeout: 60
27
+ ```
28
+
29
+ On macOS, `enabled: true` wraps every skill script in a generated
30
+ deny-by-default `sandbox-exec` (Seatbelt) profile. Elsewhere it's a passthrough
31
+ with a one-time warning — confinement is currently macOS-only. Requiring the
32
+ gem never changes behavior on its own; nothing is confined until
33
+ `sandbox.enabled` is set.
34
+
35
+ ## Navigation
36
+
37
+ - [Getting Started](getting_started.md) — installation, enabling, a minimal example, checking what's installed
38
+ - [Configuration](configuration.md) — the `sandbox:` ceiling, `SKILL.md` capability declarations, and how the two intersect
39
+ - [How It Works](how_it_works.md) — the `ScriptTool.executor` extension point, `Sandbox.for` strategy selection, the generated Seatbelt profile, timeout enforcement, extension registration
40
+ - [Troubleshooting](troubleshooting.md) — the interpreter-under-`$HOME` problem, non-macOS behavior, diagnosing a denied path
41
+
42
+ ## At a Glance
43
+
44
+ | | |
45
+ |---|---|
46
+ | **Confines** | `RobotLab::ScriptTool` executions — i.e. AgentSkill `scripts/` shelled out as tools |
47
+ | **Strategies** | `Sandbox::Seatbelt` (macOS, `sandbox-exec`), `Sandbox::Null` (passthrough) |
48
+ | **Opt-in via** | `config.sandbox.enabled` (default `false`) — requiring this gem does not turn it on |
49
+ | **Grant model** | effective grant = skill's declared `Capabilities` ∩ config `sandbox:` ceiling |
50
+ | **Always unconfined** | `trust: core` skills, regardless of platform or config |
51
+ | **Extension point** | `RobotLab::ScriptTool.executor = RobotLab::Sandbox::Executor` |
52
+ | **Timeout enforcement** | only when this gem is loaded and sandboxing is enabled |
53
+
54
+ ## Links
55
+
56
+ - [RobotLab Core](https://github.com/MadBomber/robot_lab)
57
+ - [RubyGems](https://rubygems.org/gems/robot_lab-sandbox)
58
+ - [GitHub](https://github.com/MadBomber/robot_lab-sandbox)
@@ -0,0 +1,108 @@
1
+ # Troubleshooting
2
+
3
+ ## Interpreter installed under `$HOME` is invisible
4
+
5
+ **Symptom:** a script fails immediately with something like
6
+ `env: ruby: No such file or directory` or `bad interpreter`, but the same
7
+ script runs fine with `sandbox.enabled: false`.
8
+
9
+ **Cause:** the generated Seatbelt profile never grants read access to
10
+ `$HOME`, on purpose — that's what keeps SSH keys and cloud credentials out of
11
+ reach of a confined script. But it also means any interpreter installed
12
+ under `$HOME` (rbenv, asdf, mise, a Homebrew prefix under `~`, `~/.gem`, a
13
+ project-local `bundle` path under `~/.bundle`) is invisible to the sandboxed
14
+ process, and the script can't even start.
15
+
16
+ **Fix:** either
17
+
18
+ - Grant the interpreter's install path explicitly in the skill's `fs_read`:
19
+ ```yaml
20
+ fs_read: ["~/.rbenv"]
21
+ ```
22
+ (this still goes through the config `sandbox.fs_read` ceiling — the
23
+ ceiling must permit it too, see [Configuration](configuration.md#the-intersection)), or
24
+ - Mark the skill `trust: core` if you wrote and audited it yourself — this
25
+ bypasses confinement entirely, see [Configuration — trust: core](configuration.md#trust-core-bypassing-confinement).
26
+
27
+ ## A path I granted is still denied
28
+
29
+ **Symptom:** a script writes to (or reads from) a path you declared in
30
+ `fs_write`/`fs_read`, but the write silently fails or the read comes back
31
+ empty, even though `sandbox.enabled: true` and the skill's front matter
32
+ looks right.
33
+
34
+ **Checklist:**
35
+
36
+ 1. **Is the path inside the config ceiling?** The effective grant is the
37
+ *intersection* of what the skill declares and `config.sandbox.fs_read`/
38
+ `fs_write` — a path outside every ceiling root is dropped silently, even
39
+ if the skill asks for it. Check `RobotLab::Capabilities.ceiling.fs_read`
40
+ / `.fs_write` in a console.
41
+ 2. **Is it a symlinked path?** macOS symlinks `/tmp` → `/private/tmp`,
42
+ `/var` → `/private/var`. The profile is generated against the resolved
43
+ real path — if you're comparing against the logical path elsewhere (e.g.
44
+ in a test assertion), resolve it with `File.realpath` first. See
45
+ [How It Works — Path canonicalization](how_it_works.md#path-canonicalization).
46
+ 3. **Does the path exist yet, for a write target?** Canonicalization walks up
47
+ to the nearest existing ancestor to resolve symlinks, then re-appends the
48
+ rest — if an intermediate directory in the path doesn't exist and is
49
+ *itself* a symlink once created, the grant can end up pointing at the
50
+ wrong real path. Prefer granting an existing parent directory over a
51
+ not-yet-created nested path.
52
+ 4. **Is `trust: core` set?** A `core` skill bypasses the sandbox entirely —
53
+ if you're debugging a *denial* and the skill is `trust: core`, the
54
+ sandbox isn't involved at all; look elsewhere (permissions, the script
55
+ itself).
56
+
57
+ You can inspect the exact profile a grant produces without running anything:
58
+
59
+ ```ruby
60
+ grant = RobotLab::Capabilities.new(fs_read: ["./data"], fs_write: ["./out"])
61
+ puts RobotLab::Sandbox::Seatbelt.new(grant, skill_dir: "./skills/example").profile_text
62
+ ```
63
+
64
+ ## Nothing is confined, even with `sandbox.enabled: true`
65
+
66
+ **Symptom:** scripts still behave as if sandboxing were off — no denials,
67
+ no warnings, everything just works as before.
68
+
69
+ **Checklist:**
70
+
71
+ 1. **Is `robot_lab/sandbox` actually required?** `require "robot_lab/sandbox"`
72
+ must run *after* `require "robot_lab"`. Check:
73
+ ```ruby
74
+ RobotLab::ScriptTool.executor # nil means this gem was never loaded
75
+ ```
76
+ 2. **Is the skill `trust: core`?** Always bypasses confinement — see above.
77
+ 3. **Are you off macOS?** Confinement is macOS-only; elsewhere every script
78
+ runs through `Sandbox::Null` (a passthrough) with a one-time warning
79
+ logged (`Sandbox: OS-level confinement is only available on macOS; ...`).
80
+ Check `RobotLab::Sandbox.macos?`.
81
+
82
+ ## A script that used to finish now gets killed with `[killed: exceeded Ns]`
83
+
84
+ **Cause:** once this gem is loaded and sandboxing is enabled, every script
85
+ run is bounded by the effective grant's `timeout` — the smaller of the
86
+ skill's declared `timeout:` and `config.sandbox.timeout` (default `60`).
87
+ Core alone never enforced a timeout at all, so a slow script that worked
88
+ fine before can now hit this for the first time.
89
+
90
+ **Fix:** raise the skill's declared `timeout:` in its `SKILL.md` front
91
+ matter, and/or raise the config ceiling's `sandbox.timeout` — whichever is
92
+ currently the smaller (and therefore binding) value.
93
+
94
+ ## `robot_lab/sandbox` raises on require
95
+
96
+ ```
97
+ RobotLab::Sandbox::Error: robot_lab must be loaded before robot_lab/sandbox
98
+ ```
99
+
100
+ **Cause:** `require "robot_lab/sandbox"` ran before `require "robot_lab"` (or
101
+ `robot_lab` failed to load for some other reason first). `robot_lab/sandbox`
102
+ checks `defined?(RobotLab::ScriptTool)` at load time and raises immediately
103
+ rather than silently no-op-ing, since a sandbox gem that failed to install
104
+ itself and said nothing would be far more dangerous than one that's loud
105
+ about it.
106
+
107
+ **Fix:** reorder your `require`s (or Gemfile-driven autoload order) so
108
+ `robot_lab` loads first.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module RobotLab
7
+ module Sandbox
8
+ # Installed as RobotLab::ScriptTool.executor when this gem loads. Runs a
9
+ # skill script unconfined when sandboxing is disabled (config.sandbox.enabled
10
+ # is false, the default), or confined and timeout-bounded when it is enabled.
11
+ module Executor
12
+ module_function
13
+
14
+ # @param cmd [Array<String>] command to run
15
+ # @param capabilities [Capabilities] declared capabilities (from SKILL.md)
16
+ # @param skill_dir [String] skill bundle root
17
+ # @return [String] combined stdout+stderr, or an error string on failure
18
+ def call(cmd, capabilities:, skill_dir:)
19
+ unless Sandbox.enabled?
20
+ output, status = Open3.capture2e(*cmd)
21
+ return RobotLab::ScriptTool.format_result(output, status)
22
+ end
23
+
24
+ grant = capabilities.intersect(Capabilities.ceiling)
25
+ sandbox = Sandbox.for(grant, skill_dir: skill_dir)
26
+ begin
27
+ output, status = run_with_timeout(sandbox.wrap(cmd), grant.timeout)
28
+ RobotLab::ScriptTool.format_result(output, status)
29
+ ensure
30
+ sandbox.cleanup
31
+ end
32
+ end
33
+
34
+ # @return [Array(String, Process::Status|nil)] output and status (nil = timed out)
35
+ def run_with_timeout(cmd, timeout)
36
+ Open3.popen2e(*cmd, pgroup: true) do |stdin, out, wait|
37
+ stdin.close
38
+ output = +""
39
+ begin
40
+ Timeout.timeout(timeout) { output << out.read }
41
+ rescue Timeout::Error
42
+ terminate(wait.pid)
43
+ return ["#{output}\n[killed: exceeded #{timeout}s]", nil]
44
+ end
45
+ [output, wait.value]
46
+ end
47
+ end
48
+
49
+ def terminate(pid)
50
+ Process.kill("-TERM", Process.getpgid(pid))
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RobotLab
4
+ module Sandbox
5
+ # Passthrough strategy: runs the command with no confinement. Used when
6
+ # sandboxing is unavailable (non-macOS) or unnecessary (trust: core).
7
+ class Null
8
+ def wrap(cmd) = cmd
9
+
10
+ def cleanup; end
11
+ end
12
+ end
13
+ end