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.
- checksums.yaml +7 -0
- data/.envrc +6 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/CHANGELOG.md +21 -0
- data/CLAUDE.md +55 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +66 -0
- data/Rakefile +24 -0
- data/docs/configuration.md +112 -0
- data/docs/getting_started.md +121 -0
- data/docs/how_it_works.md +236 -0
- data/docs/index.md +58 -0
- data/docs/troubleshooting.md +108 -0
- data/lib/robot_lab/sandbox/executor.rb +56 -0
- data/lib/robot_lab/sandbox/null.rb +13 -0
- data/lib/robot_lab/sandbox/seatbelt.rb +106 -0
- data/lib/robot_lab/sandbox/version.rb +7 -0
- data/lib/robot_lab/sandbox.rb +74 -0
- data/mkdocs.yml +118 -0
- metadata +82 -0
|
@@ -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
|