agent_jail 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +107 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +21 -0
- data/README.md +243 -0
- data/Rakefile +10 -0
- data/lib/agent_jail/child.rb +55 -0
- data/lib/agent_jail/configuration.rb +23 -0
- data/lib/agent_jail/errors.rb +31 -0
- data/lib/agent_jail/ffi/landlock.rb +95 -0
- data/lib/agent_jail/ffi/seatbelt.rb +38 -0
- data/lib/agent_jail/ffi/setrlimit.rb +47 -0
- data/lib/agent_jail/pipe.rb +27 -0
- data/lib/agent_jail/platform.rb +42 -0
- data/lib/agent_jail/restrictions/base.rb +14 -0
- data/lib/agent_jail/restrictions/landlock.rb +64 -0
- data/lib/agent_jail/restrictions/resource_limits.rb +20 -0
- data/lib/agent_jail/restrictions/seatbelt.rb +65 -0
- data/lib/agent_jail/runner.rb +109 -0
- data/lib/agent_jail/version.rb +5 -0
- data/lib/agent_jail.rb +72 -0
- data/sig/agent_jail.rbs +41 -0
- metadata +87 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cbe7c9edb7dc3c7d7300b5617162dd80bc63d278d83a42f8afbede50bf2e45f1
|
|
4
|
+
data.tar.gz: f50d55b8ff825febc26a1c74592f91ca70ca0fcd0ee90753cf44d8aac4461b7d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6a2f0343e61d99643735a35d6cba6b871fe9f176bf2168375c88e4d0bc7150da8b07717bafd4873055a2ec60d40520c9072f678c3184ecd71fbdd8a89df76a45
|
|
7
|
+
data.tar.gz: a9964bff1b0ffc418cda699527b2aa0e7fd7c1e7830314c849a08a9e1dfe355860635aa099ba4e62bba4ac35f3fe2c9df8e27c0ada7909ab33a9a58b8495e7bf
|
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
plugins:
|
|
2
|
+
- rubocop-rspec
|
|
3
|
+
|
|
4
|
+
AllCops:
|
|
5
|
+
NewCops: enable
|
|
6
|
+
TargetRubyVersion: 3.2
|
|
7
|
+
Exclude:
|
|
8
|
+
- "bin/**/*"
|
|
9
|
+
- "vendor/**/*"
|
|
10
|
+
- "pkg/**/*"
|
|
11
|
+
|
|
12
|
+
Style/StringLiterals:
|
|
13
|
+
EnforcedStyle: double_quotes
|
|
14
|
+
|
|
15
|
+
Style/FrozenStringLiteralComment:
|
|
16
|
+
Enabled: true
|
|
17
|
+
|
|
18
|
+
Metrics/MethodLength:
|
|
19
|
+
Max: 25
|
|
20
|
+
|
|
21
|
+
Metrics/BlockLength:
|
|
22
|
+
Max: 50
|
|
23
|
+
Exclude:
|
|
24
|
+
- "spec/**/*"
|
|
25
|
+
|
|
26
|
+
Metrics/ClassLength:
|
|
27
|
+
Max: 150
|
|
28
|
+
|
|
29
|
+
Metrics/ModuleLength:
|
|
30
|
+
Max: 150
|
|
31
|
+
|
|
32
|
+
Style/Documentation:
|
|
33
|
+
Enabled: false
|
|
34
|
+
|
|
35
|
+
Naming/MethodParameterName:
|
|
36
|
+
MinNameLength: 1
|
|
37
|
+
|
|
38
|
+
Naming/PredicateMethod:
|
|
39
|
+
Enabled: false
|
|
40
|
+
|
|
41
|
+
RSpec/VerifiedDoubles:
|
|
42
|
+
Enabled: false
|
|
43
|
+
|
|
44
|
+
RSpec/MessageSpies:
|
|
45
|
+
Enabled: false
|
|
46
|
+
|
|
47
|
+
RSpec/IdenticalEqualityAssertion:
|
|
48
|
+
Enabled: false
|
|
49
|
+
|
|
50
|
+
RSpec/StubbedMock:
|
|
51
|
+
Enabled: false
|
|
52
|
+
|
|
53
|
+
Metrics/AbcSize:
|
|
54
|
+
Max: 40
|
|
55
|
+
|
|
56
|
+
Metrics/CyclomaticComplexity:
|
|
57
|
+
Max: 10
|
|
58
|
+
|
|
59
|
+
Metrics/PerceivedComplexity:
|
|
60
|
+
Max: 12
|
|
61
|
+
|
|
62
|
+
Metrics/ParameterLists:
|
|
63
|
+
Max: 8
|
|
64
|
+
|
|
65
|
+
RSpec/SpecFilePathFormat:
|
|
66
|
+
Enabled: false
|
|
67
|
+
|
|
68
|
+
RSpec/MultipleExpectations:
|
|
69
|
+
Max: 8
|
|
70
|
+
|
|
71
|
+
RSpec/ExampleLength:
|
|
72
|
+
Max: 30
|
|
73
|
+
|
|
74
|
+
RSpec/MultipleMemoizedHelpers:
|
|
75
|
+
Max: 10
|
|
76
|
+
|
|
77
|
+
Lint/EmptyBlock:
|
|
78
|
+
Exclude:
|
|
79
|
+
- "spec/**/*"
|
|
80
|
+
|
|
81
|
+
# Security/MarshalLoad is acknowledged — we only load data we wrote ourselves
|
|
82
|
+
# (child → parent pipe), never from external input.
|
|
83
|
+
Security/MarshalLoad:
|
|
84
|
+
Enabled: false
|
|
85
|
+
|
|
86
|
+
# Lint/RescueException is needed in Child#run to catch all exceptions from the block
|
|
87
|
+
# and forward them over the pipe rather than crashing the child process.
|
|
88
|
+
Lint/RescueException:
|
|
89
|
+
Exclude:
|
|
90
|
+
- "lib/agent_jail/child.rb"
|
|
91
|
+
|
|
92
|
+
# set_memory / set_cpu are not property setters; they apply OS-level resource limits.
|
|
93
|
+
Naming/AccessorMethodName:
|
|
94
|
+
Enabled: false
|
|
95
|
+
|
|
96
|
+
# Spec files legitimately test multiple related classes in one file.
|
|
97
|
+
RSpec/MultipleDescribes:
|
|
98
|
+
Enabled: false
|
|
99
|
+
|
|
100
|
+
# Common RSpec pattern: multiline expect block followed by assertion on the error.
|
|
101
|
+
Style/MultilineBlockChain:
|
|
102
|
+
Enabled: false
|
|
103
|
+
|
|
104
|
+
# $test_agent_jail_var is used to assert fork isolation (child doesn't mutate parent globals).
|
|
105
|
+
Style/GlobalVars:
|
|
106
|
+
Exclude:
|
|
107
|
+
- "spec/**/*"
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — 2026-06-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `AgentJail.run` — fork + pipe + monitor pattern for sandboxed block execution
|
|
7
|
+
- Linux Landlock LSM filesystem restrictions (kernel >= 5.13) via FFI syscalls (444/445/446)
|
|
8
|
+
- macOS Seatbelt (`sandbox_init`) filesystem restrictions via FFI
|
|
9
|
+
- POSIX resource limits: address space (`RLIMIT_AS`) and CPU time (`RLIMIT_CPU`) via `setrlimit`
|
|
10
|
+
- Wall-clock timeout enforced by a monitor thread in the parent process
|
|
11
|
+
- Typed exception hierarchy: `TimeoutError`, `MemoryError`, `FilesystemError`, `SandboxError`, `UnsupportedPlatformError`
|
|
12
|
+
- `SandboxError` wraps block exceptions with `original_class`, `original_message`, `original_backtrace`
|
|
13
|
+
- `AgentJail.configure` block with `default_timeout`, `default_memory_mb`, `default_fs_allow`, `on_unsupported`
|
|
14
|
+
- Graceful degradation on unsupported platforms (`:warn`, `:raise`, `:ignore`)
|
|
15
|
+
- `fs_allow` (read-write) and `fs_read_allow` (read-only) per-run path lists
|
|
16
|
+
- Implicit system read-only paths so Ruby functions correctly inside the sandbox
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jibran Usman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# agent_jail
|
|
2
|
+
|
|
3
|
+
**Run LLM-generated tool calls in a sandboxed child process — timeout, memory limit, filesystem restrictions.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/jibranusman95/agent_jail/actions)
|
|
6
|
+
[](https://badge.fury.io/rb/agent_jail)
|
|
7
|
+
[](https://rubygems.org/gems/agent_jail)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You're building a Rails agent. The LLM generates a tool call. The tool call loops forever, allocates 10GB, or tries to read `/etc/passwd`.
|
|
13
|
+
|
|
14
|
+
You have no clean way to stop it.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# No protection — this blocks your process indefinitely
|
|
18
|
+
result = tool.call(llm_generated_args)
|
|
19
|
+
|
|
20
|
+
# Or kills your server
|
|
21
|
+
result = eval(llm_generated_code)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`agent_jail` runs the block in a forked child process with real OS-level restrictions:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
result = AgentJail.run(timeout: 5, memory_mb: 256, fs_allow: ["/app/tmp"]) do
|
|
28
|
+
tool.call(llm_generated_args)
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The child is isolated. If it misbehaves, it's killed. The parent gets a typed exception — no crash, no data loss, no runaway process.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## What you get
|
|
37
|
+
|
|
38
|
+
**Timeout enforcement** — wall-clock timeout via a monitor thread in the parent plus CPU time limit via `RLIMIT_CPU`. A sleeping infinite loop is killed just like a CPU-burning one.
|
|
39
|
+
|
|
40
|
+
**Memory limit** — address space limit via `RLIMIT_AS` (`setrlimit`). The child is killed before it can OOM your server.
|
|
41
|
+
|
|
42
|
+
**Filesystem restrictions (Linux 5.13+)** — Linux Landlock LSM via direct syscalls (no root, no capabilities needed). Paths outside `fs_allow` are inaccessible at the kernel level, not just at the Ruby level.
|
|
43
|
+
|
|
44
|
+
**macOS sandbox** — Seatbelt (`sandbox_init`) profile generated from `fs_allow` and applied in the child.
|
|
45
|
+
|
|
46
|
+
**Typed exceptions** — `TimeoutError`, `MemoryError`, `FilesystemError`, `SandboxError` (wraps block exceptions), `UnsupportedPlatformError`. Catch exactly what you need.
|
|
47
|
+
|
|
48
|
+
**Block isolation** — uses `fork`, so the block has full access to in-memory state (open connections, loaded gems, closures) without serialization. The child's mutations never affect the parent.
|
|
49
|
+
|
|
50
|
+
**Graceful degradation** — on Windows or Linux < 5.13, runs the block with resource limits only (or raises, or silently runs unsandboxed — your choice).
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Platform support
|
|
55
|
+
|
|
56
|
+
| Platform | Filesystem isolation | Resource limits | Notes |
|
|
57
|
+
|----------|---------------------|-----------------|-------|
|
|
58
|
+
| Linux >= 5.13 | Landlock LSM | `setrlimit` + wall-clock | Full support |
|
|
59
|
+
| Linux < 5.13 | None (warning logged) | `setrlimit` + wall-clock | Resource limits only |
|
|
60
|
+
| macOS | Seatbelt (`sandbox_init`) | `setrlimit` + wall-clock | Partial — `sandbox_init` is deprecated but functional |
|
|
61
|
+
| Windows | None | None | No-op with warning; set `on_unsupported: :raise` to hard-fail |
|
|
62
|
+
| JRuby / TruffleRuby | None | None | `fork` not available; behaves like Windows |
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Gemfile
|
|
70
|
+
gem "agent_jail"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
bundle install
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires Ruby >= 3.2.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### Basic — timeout and memory
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
require "agent_jail"
|
|
87
|
+
|
|
88
|
+
result = AgentJail.run(timeout: 5, memory_mb: 256) do
|
|
89
|
+
SomeToolCall.execute(args)
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### With filesystem restrictions
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
result = AgentJail.run(
|
|
97
|
+
timeout: 10,
|
|
98
|
+
memory_mb: 512,
|
|
99
|
+
fs_allow: ["/app/tmp", "/app/uploads"], # read-write
|
|
100
|
+
fs_read_allow: ["/app/config"] # read-only
|
|
101
|
+
) do
|
|
102
|
+
tool.call(args)
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### All options
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
result = AgentJail.run(
|
|
110
|
+
timeout: 10, # wall-clock seconds before child is killed (default: 30)
|
|
111
|
+
cpu_timeout: 5, # CPU seconds before child is killed (default: same as timeout)
|
|
112
|
+
memory_mb: 256, # address space limit in MB (default: 512)
|
|
113
|
+
fs_allow: ["/app/tmp"], # read-write paths (default: [])
|
|
114
|
+
fs_read_allow: ["/app/config"] # read-only paths (default: [])
|
|
115
|
+
) do
|
|
116
|
+
tool.call(args)
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Configuration
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
AgentJail.configure do |c|
|
|
124
|
+
c.default_timeout = 30 # seconds
|
|
125
|
+
c.default_memory_mb = 512 # MB
|
|
126
|
+
c.default_fs_allow = []
|
|
127
|
+
c.on_unsupported = :warn # :warn (default), :raise, or :ignore
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`on_unsupported` controls behaviour on platforms where sandboxing is unavailable:
|
|
132
|
+
|
|
133
|
+
| Value | Behaviour |
|
|
134
|
+
|-------|-----------|
|
|
135
|
+
| `:warn` | Logs a warning to stderr, runs block unsandboxed |
|
|
136
|
+
| `:raise` | Raises `AgentJail::UnsupportedPlatformError` |
|
|
137
|
+
| `:ignore` | Runs block unsandboxed silently |
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Exceptions
|
|
142
|
+
|
|
143
|
+
| Exception | Raised when |
|
|
144
|
+
|-----------|-------------|
|
|
145
|
+
| `AgentJail::TimeoutError` | Block exceeded wall-clock or CPU time limit |
|
|
146
|
+
| `AgentJail::MemoryError` | Block exceeded memory (address space) limit |
|
|
147
|
+
| `AgentJail::FilesystemError` | Block accessed a path outside `fs_allow` (Linux Landlock) |
|
|
148
|
+
| `AgentJail::SandboxError` | Block raised an exception — wraps original with `original_class`, `original_message`, `original_backtrace` |
|
|
149
|
+
| `AgentJail::UnsupportedPlatformError` | Platform can't sandbox and `on_unsupported: :raise` is set |
|
|
150
|
+
|
|
151
|
+
All inherit from `AgentJail::Error < StandardError`.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
begin
|
|
155
|
+
AgentJail.run(timeout: 5) { tool.call(args) }
|
|
156
|
+
rescue AgentJail::TimeoutError
|
|
157
|
+
# handle timeout
|
|
158
|
+
rescue AgentJail::MemoryError
|
|
159
|
+
# handle OOM
|
|
160
|
+
rescue AgentJail::FilesystemError
|
|
161
|
+
# handle filesystem violation
|
|
162
|
+
rescue AgentJail::SandboxError => e
|
|
163
|
+
logger.error("Tool call raised #{e.original_class}: #{e.original_message}")
|
|
164
|
+
logger.error(e.original_backtrace.join("\n"))
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## How it works
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
AgentJail.run { block }
|
|
174
|
+
│
|
|
175
|
+
├── opens result_pipe (r, w)
|
|
176
|
+
├── fork → Child process
|
|
177
|
+
│ │
|
|
178
|
+
│ ├── closes r
|
|
179
|
+
│ ├── setrlimit (RLIMIT_AS + RLIMIT_CPU)
|
|
180
|
+
│ ├── Landlock / sandbox_init (if supported)
|
|
181
|
+
│ ├── runs block
|
|
182
|
+
│ ├── marshals result → writes to w
|
|
183
|
+
│ └── exit!(0)
|
|
184
|
+
│
|
|
185
|
+
├── closes w
|
|
186
|
+
├── starts monitor thread (wall-clock timeout → SIGKILL)
|
|
187
|
+
├── reads result_pipe until EOF
|
|
188
|
+
├── Process.waitpid2(child_pid)
|
|
189
|
+
├── kills monitor thread
|
|
190
|
+
└── unmarshals result OR raises TimeoutError/MemoryError
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Why fork?** `fork` copies the full Ruby process including loaded gems, open connections, and closures. The block can reference any in-scope object without serialization. Threads share memory (no isolation); `spawn` requires serializing the block (loses closures, complex). `fork` is the right tool.
|
|
194
|
+
|
|
195
|
+
**Why both wall-clock and CPU timeout?** `RLIMIT_CPU` limits CPU time — a sleeping process uses no CPU and would never be killed. The monitor thread kills by wall-clock regardless of CPU usage.
|
|
196
|
+
|
|
197
|
+
**Landlock design:** Landlock ABI v1 syscalls (444/445/446) via FFI. No root or capabilities required — just `prctl(PR_SET_NO_NEW_PRIVS)` first. The child creates a ruleset denying all filesystem access, adds explicit allow rules for system paths (read-only) and user-specified paths, then locks itself in with `landlock_restrict_self`.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Known sharp edges
|
|
202
|
+
|
|
203
|
+
**Threads + fork:** If your parent process has background threads when `AgentJail.run` is called, the child inherits them in a broken state. Call `AgentJail.run` from a single-threaded context where possible. Background threads in the child are not safe to use.
|
|
204
|
+
|
|
205
|
+
**Database connections:** `fork` copies open file descriptors including database connections. Do not use ActiveRecord or any connection pool in the block — the child's connections are copies of the parent's and using them will corrupt state. Read-only closures over already-fetched data are fine.
|
|
206
|
+
|
|
207
|
+
**`RLIMIT_AS` vs RSS:** `RLIMIT_AS` limits virtual address space, not physical RAM. Ruby's VM allocates large virtual ranges upfront. Set `memory_mb` generously (>= 256) to avoid killing the child on startup. The default of 512MB is conservative.
|
|
208
|
+
|
|
209
|
+
**Return value size:** The result is marshalled over a pipe. Very large return values (> 10MB) will work but are slow. If you need to return large data, write it to an allowed path and return the path.
|
|
210
|
+
|
|
211
|
+
**MRI only:** `fork` is not available on JRuby or TruffleRuby. `Platform.fork_supported?` returns false on these runtimes and `on_unsupported` kicks in.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Requirements
|
|
216
|
+
|
|
217
|
+
- Ruby >= 3.2.0
|
|
218
|
+
- Linux kernel >= 5.13 for Landlock filesystem restrictions (kernel detection is automatic)
|
|
219
|
+
- `ffi` gem (the only runtime dependency)
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Contributing
|
|
224
|
+
|
|
225
|
+
Bug reports and pull requests are welcome at https://github.com/jibranusman95/agent_jail.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## From the same author
|
|
230
|
+
|
|
231
|
+
| Gem | What it does |
|
|
232
|
+
|-----|-------------|
|
|
233
|
+
| [http_decoy](https://github.com/jibranusman95/http_decoy) | A real Rack server that runs inside your RSpec tests — test HTTP contracts without WebMock stubs |
|
|
234
|
+
| [llm_cassette](https://github.com/jibranusman95/llm_cassette) | Streaming-aware cassette recorder for LLM calls — record once, replay fast |
|
|
235
|
+
| [webhook_inbox](https://github.com/jibranusman95/webhook_inbox) | Transactional webhook inbox for Rails — deduplicate, replay, inspect |
|
|
236
|
+
| [promptscrub](https://github.com/jibranusman95/promptscrub) | Bidirectional PII redaction middleware for LLM calls |
|
|
237
|
+
| [turbo_presence](https://github.com/jibranusman95/turbo_presence) | Figma-style live cursors and presence in Rails with one line |
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT — see [LICENSE](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
# Runs inside the forked child process.
|
|
5
|
+
# Applies restrictions then executes the block, writing the result to the pipe.
|
|
6
|
+
class Child
|
|
7
|
+
def initialize(write:, cpu_timeout:, memory_mb:, fs_allow:, fs_read_allow:, block:)
|
|
8
|
+
@write = write
|
|
9
|
+
@cpu_timeout = cpu_timeout
|
|
10
|
+
@memory_mb = memory_mb
|
|
11
|
+
@fs_allow = fs_allow
|
|
12
|
+
@fs_read_allow = fs_read_allow
|
|
13
|
+
@block = block
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
apply_restrictions
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
result = @block.call
|
|
21
|
+
Pipe.write_result(@write, result)
|
|
22
|
+
rescue Exception => e
|
|
23
|
+
Pipe.write_error(@write, e)
|
|
24
|
+
ensure
|
|
25
|
+
@write.close
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def apply_restrictions
|
|
32
|
+
# Apply CPU limit first to prevent runaway setup code.
|
|
33
|
+
# Apply filesystem restrictions before the memory (RLIMIT_AS) limit,
|
|
34
|
+
# because Landlock setup allocates memory for FFI structs and file descriptors.
|
|
35
|
+
# Setting RLIMIT_AS too early can cause the setup itself to OOM.
|
|
36
|
+
AgentJail::FFI::Setrlimit.set_cpu(@cpu_timeout)
|
|
37
|
+
apply_filesystem_restrictions
|
|
38
|
+
AgentJail::FFI::Setrlimit.set_memory(@memory_mb * 1024 * 1024)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_filesystem_restrictions
|
|
42
|
+
if Platform.landlock_supported?
|
|
43
|
+
Restrictions::Landlock.new(
|
|
44
|
+
fs_allow: @fs_allow,
|
|
45
|
+
fs_read_allow: @fs_read_allow
|
|
46
|
+
).apply
|
|
47
|
+
elsif Platform.macos?
|
|
48
|
+
Restrictions::Seatbelt.new(
|
|
49
|
+
fs_allow: @fs_allow,
|
|
50
|
+
fs_read_allow: @fs_read_allow
|
|
51
|
+
).apply
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
class Configuration
|
|
5
|
+
VALID_ON_UNSUPPORTED = %i[warn raise ignore].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :default_timeout, :default_memory_mb, :default_fs_allow, :on_unsupported
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@default_timeout = 30
|
|
11
|
+
@default_memory_mb = 512
|
|
12
|
+
@default_fs_allow = []
|
|
13
|
+
@on_unsupported = :warn
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def validate!
|
|
17
|
+
return if VALID_ON_UNSUPPORTED.include?(@on_unsupported)
|
|
18
|
+
|
|
19
|
+
raise ArgumentError,
|
|
20
|
+
"on_unsupported must be one of #{VALID_ON_UNSUPPORTED.inspect}, got #{@on_unsupported.inspect}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
# Base error for all agent_jail exceptions.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the sandboxed block exceeds the wall-clock or CPU time limit.
|
|
8
|
+
class TimeoutError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when the sandboxed block exceeds the memory (address space) limit.
|
|
11
|
+
class MemoryError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when the sandboxed block attempts to access a path not in fs_allow.
|
|
14
|
+
class FilesystemError < Error; end
|
|
15
|
+
|
|
16
|
+
# Raised when on_unsupported: :raise and the current platform cannot sandbox.
|
|
17
|
+
class UnsupportedPlatformError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when the sandboxed block raises an exception.
|
|
20
|
+
# Wraps the original exception without requiring the original class to be present.
|
|
21
|
+
class SandboxError < Error
|
|
22
|
+
attr_reader :original_class, :original_message, :original_backtrace
|
|
23
|
+
|
|
24
|
+
def initialize(original_class:, original_message:, original_backtrace:)
|
|
25
|
+
@original_class = original_class
|
|
26
|
+
@original_message = original_message
|
|
27
|
+
@original_backtrace = original_backtrace
|
|
28
|
+
super("#{original_class}: #{original_message}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module AgentJail
|
|
6
|
+
module FFI
|
|
7
|
+
# FFI bindings for the Linux Landlock LSM (kernel 5.13+).
|
|
8
|
+
# Uses raw syscalls via libc's syscall(2) since Landlock has no libc wrappers.
|
|
9
|
+
module Landlock
|
|
10
|
+
extend ::FFI::Library
|
|
11
|
+
|
|
12
|
+
ffi_lib ::FFI::Library::LIBC
|
|
13
|
+
|
|
14
|
+
# Landlock ABI v1 filesystem access rights
|
|
15
|
+
ACCESS_FS_EXECUTE = 1 << 0
|
|
16
|
+
ACCESS_FS_WRITE_FILE = 1 << 1
|
|
17
|
+
ACCESS_FS_READ_FILE = 1 << 2
|
|
18
|
+
ACCESS_FS_READ_DIR = 1 << 3
|
|
19
|
+
ACCESS_FS_REMOVE_DIR = 1 << 4
|
|
20
|
+
ACCESS_FS_REMOVE_FILE = 1 << 5
|
|
21
|
+
ACCESS_FS_MAKE_CHAR = 1 << 6
|
|
22
|
+
ACCESS_FS_MAKE_DIR = 1 << 7
|
|
23
|
+
ACCESS_FS_MAKE_REG = 1 << 8
|
|
24
|
+
ACCESS_FS_MAKE_SOCK = 1 << 9
|
|
25
|
+
ACCESS_FS_MAKE_FIFO = 1 << 10
|
|
26
|
+
ACCESS_FS_MAKE_BLOCK = 1 << 11
|
|
27
|
+
ACCESS_FS_MAKE_SYM = 1 << 12
|
|
28
|
+
|
|
29
|
+
# Composite access groups
|
|
30
|
+
ACCESS_FS_READ_WRITE = ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR |
|
|
31
|
+
ACCESS_FS_WRITE_FILE | ACCESS_FS_REMOVE_FILE |
|
|
32
|
+
ACCESS_FS_MAKE_REG | ACCESS_FS_MAKE_DIR
|
|
33
|
+
ACCESS_FS_READ_ONLY = ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR
|
|
34
|
+
|
|
35
|
+
# All ABI v1 access bits — used in ruleset's handled_access_fs
|
|
36
|
+
ALL_ACCESS_FS = ACCESS_FS_EXECUTE | ACCESS_FS_WRITE_FILE | ACCESS_FS_READ_FILE |
|
|
37
|
+
ACCESS_FS_READ_DIR | ACCESS_FS_REMOVE_DIR | ACCESS_FS_REMOVE_FILE |
|
|
38
|
+
ACCESS_FS_MAKE_CHAR | ACCESS_FS_MAKE_DIR | ACCESS_FS_MAKE_REG |
|
|
39
|
+
ACCESS_FS_MAKE_SOCK | ACCESS_FS_MAKE_FIFO | ACCESS_FS_MAKE_BLOCK |
|
|
40
|
+
ACCESS_FS_MAKE_SYM
|
|
41
|
+
|
|
42
|
+
RULE_PATH_BENEATH = 1
|
|
43
|
+
|
|
44
|
+
# Syscall numbers (x86_64 Linux)
|
|
45
|
+
SYS_LANDLOCK_CREATE_RULESET = 444
|
|
46
|
+
SYS_LANDLOCK_ADD_RULE = 445
|
|
47
|
+
SYS_LANDLOCK_RESTRICT_SELF = 446
|
|
48
|
+
|
|
49
|
+
# prctl option
|
|
50
|
+
PR_SET_NO_NEW_PRIVS = 38
|
|
51
|
+
|
|
52
|
+
# struct landlock_ruleset_attr { __u64 handled_access_fs; }
|
|
53
|
+
class RulesetAttr < ::FFI::Struct
|
|
54
|
+
layout :handled_access_fs, :uint64
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# struct landlock_path_beneath_attr — packed (no padding between fields)
|
|
58
|
+
# __u64 allowed_access; __s32 parent_fd;
|
|
59
|
+
class PathBeneathAttr < ::FFI::Struct
|
|
60
|
+
pack 1
|
|
61
|
+
layout :allowed_access, :uint64,
|
|
62
|
+
:parent_fd, :int32
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# syscall(number, ...) — varargs, each extra arg is a (type, value) pair
|
|
66
|
+
attach_function :syscall, %i[long varargs], :long
|
|
67
|
+
attach_function :prctl, %i[int ulong ulong ulong ulong], :int
|
|
68
|
+
attach_function :close, [:int], :int
|
|
69
|
+
|
|
70
|
+
def self.create_ruleset(handled_access_fs)
|
|
71
|
+
attr = RulesetAttr.new
|
|
72
|
+
attr[:handled_access_fs] = handled_access_fs
|
|
73
|
+
syscall(SYS_LANDLOCK_CREATE_RULESET, :pointer, attr.to_ptr, :size_t, attr.size, :uint32, 0)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.add_path_rule(ruleset_fd, path_fd, allowed_access)
|
|
77
|
+
attr = PathBeneathAttr.new
|
|
78
|
+
attr[:allowed_access] = allowed_access
|
|
79
|
+
attr[:parent_fd] = path_fd
|
|
80
|
+
syscall(SYS_LANDLOCK_ADD_RULE,
|
|
81
|
+
:int, ruleset_fd,
|
|
82
|
+
:uint32, RULE_PATH_BENEATH,
|
|
83
|
+
:pointer, attr.to_ptr,
|
|
84
|
+
:uint32, 0)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.restrict_self(ruleset_fd)
|
|
88
|
+
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
|
|
89
|
+
result = syscall(SYS_LANDLOCK_RESTRICT_SELF, :int, ruleset_fd, :uint32, 0)
|
|
90
|
+
close(ruleset_fd)
|
|
91
|
+
result
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module AgentJail
|
|
6
|
+
module FFI
|
|
7
|
+
# FFI bindings for macOS Seatbelt (sandbox_init).
|
|
8
|
+
# sandbox_init(3) is deprecated since macOS 10.8 but remains functional.
|
|
9
|
+
# No-op stubs are defined when libsandbox is unavailable so the module
|
|
10
|
+
# always responds to sandbox_init / sandbox_free_error regardless of platform.
|
|
11
|
+
module Seatbelt
|
|
12
|
+
extend ::FFI::Library
|
|
13
|
+
|
|
14
|
+
AVAILABLE = begin
|
|
15
|
+
ffi_lib "libsandbox.1.dylib"
|
|
16
|
+
|
|
17
|
+
# int sandbox_init(const char *profile, uint64_t flags, char **errorbuf)
|
|
18
|
+
attach_function :sandbox_init, %i[string uint64 pointer], :int
|
|
19
|
+
# void sandbox_free_error(char *errorbuf)
|
|
20
|
+
attach_function :sandbox_free_error, [:pointer], :void
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
rescue LoadError
|
|
24
|
+
# No-op stubs — callers check AVAILABLE before using; stubs keep the
|
|
25
|
+
# interface consistent and allow mocking in tests on all platforms.
|
|
26
|
+
def self.sandbox_init(_profile, _flags, _errbuf)
|
|
27
|
+
0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.sandbox_free_error(_errbuf)
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module AgentJail
|
|
6
|
+
module FFI
|
|
7
|
+
module Setrlimit
|
|
8
|
+
extend ::FFI::Library
|
|
9
|
+
|
|
10
|
+
ffi_lib ::FFI::Library::LIBC
|
|
11
|
+
|
|
12
|
+
# rlim_t is uint64 on 64-bit platforms
|
|
13
|
+
class RLimit < ::FFI::Struct
|
|
14
|
+
layout :rlim_cur, :uint64,
|
|
15
|
+
:rlim_max, :uint64
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# int setrlimit(int resource, const struct rlimit *rlim)
|
|
19
|
+
attach_function :setrlimit, %i[int pointer], :int
|
|
20
|
+
|
|
21
|
+
# POSIX resource limit constants
|
|
22
|
+
RLIMIT_CPU = 0 # CPU time in seconds
|
|
23
|
+
# RLIMIT_AS is virtual address space (address space = memory limit)
|
|
24
|
+
# Linux: 9, macOS: 5
|
|
25
|
+
RLIMIT_AS_LINUX = 9
|
|
26
|
+
RLIMIT_AS_MACOS = 5
|
|
27
|
+
|
|
28
|
+
def self.rlimit_as
|
|
29
|
+
RUBY_PLATFORM.include?("darwin") ? RLIMIT_AS_MACOS : RLIMIT_AS_LINUX
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.set_memory(bytes)
|
|
33
|
+
rlimit = RLimit.new
|
|
34
|
+
rlimit[:rlim_cur] = bytes
|
|
35
|
+
rlimit[:rlim_max] = bytes
|
|
36
|
+
setrlimit(rlimit_as, rlimit)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.set_cpu(seconds)
|
|
40
|
+
rlimit = RLimit.new
|
|
41
|
+
rlimit[:rlim_cur] = seconds
|
|
42
|
+
rlimit[:rlim_max] = seconds
|
|
43
|
+
setrlimit(RLIMIT_CPU, rlimit)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
# Handles the result pipe protocol between parent and child processes.
|
|
5
|
+
# The child writes exactly one Marshal payload (success or error).
|
|
6
|
+
# The parent reads until EOF and parses it.
|
|
7
|
+
module Pipe
|
|
8
|
+
def self.write_result(io, value)
|
|
9
|
+
io.write(Marshal.dump({ status: :ok, value: value }))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.write_error(io, exception)
|
|
13
|
+
io.write(Marshal.dump({
|
|
14
|
+
status: :error,
|
|
15
|
+
class: exception.class.name,
|
|
16
|
+
message: exception.message,
|
|
17
|
+
backtrace: exception.backtrace
|
|
18
|
+
}))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.read_result(raw)
|
|
22
|
+
return nil if raw.nil? || raw.empty?
|
|
23
|
+
|
|
24
|
+
Marshal.load(raw)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
module Platform
|
|
5
|
+
def self.linux?
|
|
6
|
+
RUBY_PLATFORM.include?("linux")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.macos?
|
|
10
|
+
RUBY_PLATFORM.include?("darwin")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.windows?
|
|
14
|
+
RUBY_PLATFORM.match?(/mingw|mswin|cygwin/)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.fork_supported?
|
|
18
|
+
linux? || macos?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.landlock_supported?
|
|
22
|
+
return false unless linux?
|
|
23
|
+
|
|
24
|
+
kernel_version >= Gem::Version.new("5.13")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.kernel_version
|
|
28
|
+
return Gem::Version.new("0.0") unless linux?
|
|
29
|
+
|
|
30
|
+
read_kernel_version
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.read_kernel_version
|
|
34
|
+
content = File.read("/proc/version")
|
|
35
|
+
match = content.match(/Linux version (\d+\.\d+)/)
|
|
36
|
+
match ? Gem::Version.new(match[1]) : Gem::Version.new("0.0")
|
|
37
|
+
rescue StandardError
|
|
38
|
+
Gem::Version.new("0.0")
|
|
39
|
+
end
|
|
40
|
+
private_class_method :read_kernel_version
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
module Restrictions
|
|
5
|
+
# Base interface for all restriction strategies.
|
|
6
|
+
# Each subclass implements #apply which is called in the child process
|
|
7
|
+
# before the sandboxed block runs.
|
|
8
|
+
class Base
|
|
9
|
+
def apply
|
|
10
|
+
raise NotImplementedError, "#{self.class}#apply not implemented"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
module Restrictions
|
|
5
|
+
# Applies Linux Landlock filesystem restrictions in the child process.
|
|
6
|
+
# Requires kernel 5.13+ and is a no-op on older kernels.
|
|
7
|
+
class Landlock < Base
|
|
8
|
+
# Paths that are implicitly allowed read-only for Ruby to function.
|
|
9
|
+
# These are added automatically regardless of user-specified fs_allow.
|
|
10
|
+
SYSTEM_READ_PATHS = %w[
|
|
11
|
+
/usr
|
|
12
|
+
/lib
|
|
13
|
+
/lib64
|
|
14
|
+
/proc
|
|
15
|
+
/dev
|
|
16
|
+
/etc
|
|
17
|
+
/run/systemd
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(fs_allow:, fs_read_allow:)
|
|
21
|
+
super()
|
|
22
|
+
@fs_allow = Array(fs_allow)
|
|
23
|
+
@fs_read_allow = Array(fs_read_allow)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def apply
|
|
27
|
+
ruleset_fd = FFI::Landlock.create_ruleset(FFI::Landlock::ALL_ACCESS_FS)
|
|
28
|
+
raise "landlock_create_ruleset failed: #{ruleset_fd}" if ruleset_fd.negative?
|
|
29
|
+
|
|
30
|
+
add_read_only_paths(ruleset_fd)
|
|
31
|
+
add_read_write_paths(ruleset_fd)
|
|
32
|
+
|
|
33
|
+
FFI::Landlock.restrict_self(ruleset_fd)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def add_read_only_paths(ruleset_fd)
|
|
39
|
+
(SYSTEM_READ_PATHS + @fs_read_allow).each do |path|
|
|
40
|
+
add_rule(ruleset_fd, path, FFI::Landlock::ACCESS_FS_READ_ONLY)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_read_write_paths(ruleset_fd)
|
|
45
|
+
@fs_allow.each do |path|
|
|
46
|
+
add_rule(ruleset_fd, path, FFI::Landlock::ACCESS_FS_READ_WRITE)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def add_rule(ruleset_fd, path, access_mask)
|
|
51
|
+
return unless File.exist?(path)
|
|
52
|
+
|
|
53
|
+
File.open(path) do |f|
|
|
54
|
+
result = FFI::Landlock.add_path_rule(ruleset_fd, f.fileno, access_mask)
|
|
55
|
+
# Negative return means the syscall failed; we ignore ENOENT/ENOTDIR silently
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
|
|
59
|
+
# Path doesn't exist or can't be opened — skip it
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
module Restrictions
|
|
5
|
+
# Applies POSIX resource limits (setrlimit) in the child process.
|
|
6
|
+
# Sets both address space (RLIMIT_AS) and CPU time (RLIMIT_CPU).
|
|
7
|
+
class ResourceLimits < Base
|
|
8
|
+
def initialize(memory_mb:, cpu_timeout:)
|
|
9
|
+
super()
|
|
10
|
+
@memory_bytes = memory_mb * 1024 * 1024
|
|
11
|
+
@cpu_timeout = cpu_timeout
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply
|
|
15
|
+
AgentJail::FFI::Setrlimit.set_memory(@memory_bytes)
|
|
16
|
+
AgentJail::FFI::Setrlimit.set_cpu(@cpu_timeout)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
module Restrictions
|
|
5
|
+
# Applies macOS Seatbelt (sandbox_init) filesystem restrictions in the child process.
|
|
6
|
+
class Seatbelt < Base
|
|
7
|
+
SYSTEM_READ_PATHS = %w[
|
|
8
|
+
/usr
|
|
9
|
+
/Library
|
|
10
|
+
/System
|
|
11
|
+
/private/var/db/dyld
|
|
12
|
+
/private/etc
|
|
13
|
+
/dev
|
|
14
|
+
/proc
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(fs_allow:, fs_read_allow:)
|
|
18
|
+
super()
|
|
19
|
+
@fs_allow = Array(fs_allow)
|
|
20
|
+
@fs_read_allow = Array(fs_read_allow)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply
|
|
24
|
+
return unless FFI::Seatbelt::AVAILABLE
|
|
25
|
+
|
|
26
|
+
profile = build_profile
|
|
27
|
+
errorbuf_ptr = ::FFI::MemoryPointer.new(:pointer)
|
|
28
|
+
result = FFI::Seatbelt.sandbox_init(profile, 0, errorbuf_ptr)
|
|
29
|
+
|
|
30
|
+
return unless result != 0
|
|
31
|
+
|
|
32
|
+
err_ptr = errorbuf_ptr.read_pointer
|
|
33
|
+
message = err_ptr.null? ? "sandbox_init failed" : err_ptr.read_string
|
|
34
|
+
FFI::Seatbelt.sandbox_free_error(err_ptr) unless err_ptr.null?
|
|
35
|
+
raise "sandbox_init failed: #{message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_profile
|
|
41
|
+
lines = ["(version 1)", "(deny default)"]
|
|
42
|
+
|
|
43
|
+
SYSTEM_READ_PATHS.each do |path|
|
|
44
|
+
lines << "(allow file-read* (subpath \"#{path}\"))"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@fs_read_allow.each do |path|
|
|
48
|
+
lines << "(allow file-read* (subpath \"#{path}\"))"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@fs_allow.each do |path|
|
|
52
|
+
lines << "(allow file-read-write* (subpath \"#{path}\"))"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
lines << "(allow process-exec*)"
|
|
56
|
+
lines << "(allow mach*)"
|
|
57
|
+
lines << "(allow sysctl*)"
|
|
58
|
+
lines << "(allow signal)"
|
|
59
|
+
lines << "(allow network* (local))"
|
|
60
|
+
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentJail
|
|
4
|
+
# Orchestrates the fork + pipe + monitor pattern.
|
|
5
|
+
# Forks a child, applies a wall-clock timeout monitor thread, reads the result.
|
|
6
|
+
class Runner
|
|
7
|
+
def initialize(options, &block)
|
|
8
|
+
cfg = AgentJail.configuration
|
|
9
|
+
@timeout = options.fetch(:timeout, cfg.default_timeout)
|
|
10
|
+
@cpu_timeout = options.fetch(:cpu_timeout, @timeout)
|
|
11
|
+
@memory_mb = options.fetch(:memory_mb, cfg.default_memory_mb)
|
|
12
|
+
@fs_allow = options.fetch(:fs_allow, cfg.default_fs_allow)
|
|
13
|
+
@fs_read_allow = options.fetch(:fs_read_allow, [])
|
|
14
|
+
@block = block
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
read, write = IO.pipe
|
|
19
|
+
|
|
20
|
+
child_pid = fork do
|
|
21
|
+
read.close
|
|
22
|
+
Child.new(
|
|
23
|
+
write: write,
|
|
24
|
+
cpu_timeout: @cpu_timeout,
|
|
25
|
+
memory_mb: @memory_mb,
|
|
26
|
+
fs_allow: @fs_allow,
|
|
27
|
+
fs_read_allow: @fs_read_allow,
|
|
28
|
+
block: @block
|
|
29
|
+
).run
|
|
30
|
+
exit!(0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
write.close
|
|
34
|
+
raw, status = wait_for_child(read, child_pid)
|
|
35
|
+
read.close
|
|
36
|
+
|
|
37
|
+
parse_result(raw, status)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def wait_for_child(read_io, child_pid)
|
|
43
|
+
monitor = Thread.new do
|
|
44
|
+
sleep(@timeout)
|
|
45
|
+
Process.kill(:KILL, child_pid)
|
|
46
|
+
rescue Errno::ESRCH
|
|
47
|
+
# Child already exited before wall-clock timeout — no-op
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raw = read_io.read
|
|
51
|
+
_pid, status = Process.waitpid2(child_pid)
|
|
52
|
+
monitor.kill
|
|
53
|
+
monitor.join
|
|
54
|
+
|
|
55
|
+
[raw, status]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_result(raw, status)
|
|
59
|
+
result = Pipe.read_result(raw)
|
|
60
|
+
return raise_from_result(result) if result
|
|
61
|
+
|
|
62
|
+
# Pipe was empty — child was killed before writing
|
|
63
|
+
raise_from_signal(status)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def raise_from_result(result)
|
|
67
|
+
case result[:status]
|
|
68
|
+
when :ok
|
|
69
|
+
result[:value]
|
|
70
|
+
when :error
|
|
71
|
+
klass = result[:class]
|
|
72
|
+
if filesystem_error?(klass)
|
|
73
|
+
raise FilesystemError, result[:message]
|
|
74
|
+
elsif memory_error?(klass)
|
|
75
|
+
raise MemoryError, result[:message]
|
|
76
|
+
else
|
|
77
|
+
raise SandboxError.new(
|
|
78
|
+
original_class: klass,
|
|
79
|
+
original_message: result[:message],
|
|
80
|
+
original_backtrace: result[:backtrace]
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def raise_from_signal(status)
|
|
87
|
+
raise MemoryError, "Process exceeded #{@memory_mb}MB memory limit" unless status.signaled?
|
|
88
|
+
|
|
89
|
+
sig = status.termsig
|
|
90
|
+
case sig
|
|
91
|
+
when Signal.list["XCPU"]
|
|
92
|
+
raise TimeoutError, "Process exceeded #{@cpu_timeout}s CPU time limit"
|
|
93
|
+
when Signal.list["SEGV"]
|
|
94
|
+
raise MemoryError, "Process exceeded #{@memory_mb}MB memory limit (SIGSEGV)"
|
|
95
|
+
else
|
|
96
|
+
# SIGKILL (9) — sent by our monitor thread (wall-clock) or OS OOM killer
|
|
97
|
+
raise TimeoutError, "Process exceeded #{@timeout}s wall-clock timeout"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def filesystem_error?(klass)
|
|
102
|
+
%w[Errno::EACCES Errno::EPERM AgentJail::FilesystemError].include?(klass)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def memory_error?(klass)
|
|
106
|
+
%w[NoMemoryError AgentJail::MemoryError].include?(klass)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/agent_jail.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "agent_jail/version"
|
|
4
|
+
require "agent_jail/configuration"
|
|
5
|
+
require "agent_jail/errors"
|
|
6
|
+
require "agent_jail/platform"
|
|
7
|
+
require "agent_jail/pipe"
|
|
8
|
+
require "agent_jail/ffi/setrlimit"
|
|
9
|
+
require "agent_jail/restrictions/base"
|
|
10
|
+
require "agent_jail/restrictions/resource_limits"
|
|
11
|
+
require "agent_jail/child"
|
|
12
|
+
require "agent_jail/runner"
|
|
13
|
+
|
|
14
|
+
if AgentJail::Platform.linux?
|
|
15
|
+
require "agent_jail/ffi/landlock"
|
|
16
|
+
require "agent_jail/restrictions/landlock"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Seatbelt files load on all platforms — ffi/seatbelt.rb sets AVAILABLE = false
|
|
20
|
+
# when libsandbox is not present, and Restrictions::Seatbelt#apply is a no-op then.
|
|
21
|
+
require "agent_jail/ffi/seatbelt"
|
|
22
|
+
require "agent_jail/restrictions/seatbelt"
|
|
23
|
+
|
|
24
|
+
module AgentJail
|
|
25
|
+
class << self
|
|
26
|
+
def configure
|
|
27
|
+
yield configuration
|
|
28
|
+
configuration.validate!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configuration
|
|
32
|
+
@configuration ||= Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset!
|
|
36
|
+
@configuration = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Run a block inside a sandboxed child process with resource limits and
|
|
40
|
+
# optional filesystem restrictions.
|
|
41
|
+
#
|
|
42
|
+
# @param timeout [Integer] wall-clock timeout in seconds (default: 30)
|
|
43
|
+
# @param cpu_timeout [Integer] CPU time limit in seconds (default: same as timeout)
|
|
44
|
+
# @param memory_mb [Integer] address space limit in MB (default: 512)
|
|
45
|
+
# @param fs_allow [Array<String>] read-write paths the child may access
|
|
46
|
+
# @param fs_read_allow [Array<String>] read-only paths the child may access
|
|
47
|
+
# @return the block's return value
|
|
48
|
+
def run(**options, &block)
|
|
49
|
+
raise ArgumentError, "block required" unless block
|
|
50
|
+
|
|
51
|
+
unless Platform.fork_supported?
|
|
52
|
+
handle_unsupported("Sandboxing is not supported on #{RUBY_PLATFORM}")
|
|
53
|
+
return block.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Runner.new(options, &block).call
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def handle_unsupported(reason)
|
|
62
|
+
case configuration.on_unsupported
|
|
63
|
+
when :raise
|
|
64
|
+
raise UnsupportedPlatformError, reason
|
|
65
|
+
when :warn
|
|
66
|
+
warn "[AgentJail] WARNING: #{reason}. Running block unsandboxed."
|
|
67
|
+
when :ignore
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/sig/agent_jail.rbs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module AgentJail
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class TimeoutError < Error
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class MemoryError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class FilesystemError < Error
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class UnsupportedPlatformError < Error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class SandboxError < Error
|
|
20
|
+
attr_reader original_class: String
|
|
21
|
+
attr_reader original_message: String
|
|
22
|
+
attr_reader original_backtrace: Array[String]?
|
|
23
|
+
|
|
24
|
+
def initialize: (original_class: String, original_message: String, original_backtrace: Array[String]?) -> void
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Configuration
|
|
28
|
+
attr_accessor default_timeout: Integer
|
|
29
|
+
attr_accessor default_memory_mb: Integer
|
|
30
|
+
attr_accessor default_fs_allow: Array[String]
|
|
31
|
+
attr_accessor on_unsupported: Symbol
|
|
32
|
+
|
|
33
|
+
def initialize: () -> void
|
|
34
|
+
def validate!: () -> void
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
38
|
+
def self.configuration: () -> Configuration
|
|
39
|
+
def self.reset!: () -> void
|
|
40
|
+
def self.run: (?timeout: Integer, ?cpu_timeout: Integer, ?memory_mb: Integer, ?fs_allow: Array[String], ?fs_read_allow: Array[String]) { () -> untyped } -> untyped
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: agent_jail
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jibran Usman
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-09 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ffi
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
description: |-
|
|
28
|
+
Forks a child process, applies Linux Landlock filesystem restrictions and POSIX resource limits
|
|
29
|
+
(setrlimit), then runs your block. If the block times out, exceeds memory, or touches a disallowed
|
|
30
|
+
path, the child is killed and the parent gets a typed exception. macOS uses Seatbelt (sandbox_init).
|
|
31
|
+
Degrades gracefully on unsupported platforms.
|
|
32
|
+
email:
|
|
33
|
+
- jibran.usman@eunasolutions.com
|
|
34
|
+
executables: []
|
|
35
|
+
extensions: []
|
|
36
|
+
extra_rdoc_files: []
|
|
37
|
+
files:
|
|
38
|
+
- ".rspec"
|
|
39
|
+
- ".rubocop.yml"
|
|
40
|
+
- CHANGELOG.md
|
|
41
|
+
- LICENSE
|
|
42
|
+
- README.md
|
|
43
|
+
- Rakefile
|
|
44
|
+
- lib/agent_jail.rb
|
|
45
|
+
- lib/agent_jail/child.rb
|
|
46
|
+
- lib/agent_jail/configuration.rb
|
|
47
|
+
- lib/agent_jail/errors.rb
|
|
48
|
+
- lib/agent_jail/ffi/landlock.rb
|
|
49
|
+
- lib/agent_jail/ffi/seatbelt.rb
|
|
50
|
+
- lib/agent_jail/ffi/setrlimit.rb
|
|
51
|
+
- lib/agent_jail/pipe.rb
|
|
52
|
+
- lib/agent_jail/platform.rb
|
|
53
|
+
- lib/agent_jail/restrictions/base.rb
|
|
54
|
+
- lib/agent_jail/restrictions/landlock.rb
|
|
55
|
+
- lib/agent_jail/restrictions/resource_limits.rb
|
|
56
|
+
- lib/agent_jail/restrictions/seatbelt.rb
|
|
57
|
+
- lib/agent_jail/runner.rb
|
|
58
|
+
- lib/agent_jail/version.rb
|
|
59
|
+
- sig/agent_jail.rbs
|
|
60
|
+
homepage: https://github.com/jibranusman95/agent_jail
|
|
61
|
+
licenses: []
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/jibranusman95/agent_jail
|
|
64
|
+
source_code_uri: https://github.com/jibranusman95/agent_jail
|
|
65
|
+
changelog_uri: https://github.com/jibranusman95/agent_jail/blob/main/CHANGELOG.md
|
|
66
|
+
rubygems_mfa_required: 'true'
|
|
67
|
+
post_install_message:
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: 3.2.0
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.5.22
|
|
83
|
+
signing_key:
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: 'Sandbox LLM tool calls in a child process: timeout, memory, and filesystem
|
|
86
|
+
limits.'
|
|
87
|
+
test_files: []
|