agent_jail
Run LLM-generated tool calls in a sandboxed child process — timeout, memory limit, filesystem restrictions.
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.
You have no clean way to stop it.
# No protection — this blocks your process indefinitely
result = tool.call(llm_generated_args)
# Or kills your server
result = eval(llm_generated_code)
agent_jail runs the block in a forked child process with real OS-level restrictions:
result = AgentJail.run(timeout: 5, memory_mb: 256, fs_allow: ["/app/tmp"]) do
tool.call(llm_generated_args)
end
The child is isolated. If it misbehaves, it's killed. The parent gets a typed exception — no crash, no data loss, no runaway process.
What you get
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.
Memory limit — address space limit via RLIMIT_AS (setrlimit). The child is killed before it can OOM your server.
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.
macOS sandbox — Seatbelt (sandbox_init) profile generated from fs_allow and applied in the child.
Typed exceptions — TimeoutError, MemoryError, FilesystemError, SandboxError (wraps block exceptions), UnsupportedPlatformError. Catch exactly what you need.
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.
Graceful degradation — on Windows or Linux < 5.13, runs the block with resource limits only (or raises, or silently runs unsandboxed — your choice).
Platform support
| Platform | Filesystem isolation | Resource limits | Notes |
|---|---|---|---|
| Linux >= 5.13 | Landlock LSM | setrlimit + wall-clock |
Full support |
| Linux < 5.13 | None (warning logged) | setrlimit + wall-clock |
Resource limits only |
| macOS | Seatbelt (sandbox_init) |
setrlimit + wall-clock |
Partial — sandbox_init is deprecated but functional |
| Windows | None | None | No-op with warning; set on_unsupported: :raise to hard-fail |
| JRuby / TruffleRuby | None | None | fork not available; behaves like Windows |
Install
# Gemfile
gem "agent_jail"
bundle install
Requires Ruby >= 3.2.
Usage
Basic — timeout and memory
require "agent_jail"
result = AgentJail.run(timeout: 5, memory_mb: 256) do
SomeToolCall.execute(args)
end
With filesystem restrictions
result = AgentJail.run(
timeout: 10,
memory_mb: 512,
fs_allow: ["/app/tmp", "/app/uploads"], # read-write
fs_read_allow: ["/app/config"] # read-only
) do
tool.call(args)
end
All options
result = AgentJail.run(
timeout: 10, # wall-clock seconds before child is killed (default: 30)
cpu_timeout: 5, # CPU seconds before child is killed (default: same as timeout)
memory_mb: 256, # address space limit in MB (default: 512)
fs_allow: ["/app/tmp"], # read-write paths (default: [])
fs_read_allow: ["/app/config"] # read-only paths (default: [])
) do
tool.call(args)
end
Configuration
AgentJail.configure do |c|
c.default_timeout = 30 # seconds
c.default_memory_mb = 512 # MB
c.default_fs_allow = []
c.on_unsupported = :warn # :warn (default), :raise, or :ignore
end
on_unsupported controls behaviour on platforms where sandboxing is unavailable:
| Value | Behaviour |
|---|---|
:warn |
Logs a warning to stderr, runs block unsandboxed |
:raise |
Raises AgentJail::UnsupportedPlatformError |
:ignore |
Runs block unsandboxed silently |
Exceptions
| Exception | Raised when |
|---|---|
AgentJail::TimeoutError |
Block exceeded wall-clock or CPU time limit |
AgentJail::MemoryError |
Block exceeded memory (address space) limit |
AgentJail::FilesystemError |
Block accessed a path outside fs_allow (Linux Landlock) |
AgentJail::SandboxError |
Block raised an exception — wraps original with original_class, original_message, original_backtrace |
AgentJail::UnsupportedPlatformError |
Platform can't sandbox and on_unsupported: :raise is set |
All inherit from AgentJail::Error < StandardError.
begin
AgentJail.run(timeout: 5) { tool.call(args) }
rescue AgentJail::TimeoutError
# handle timeout
rescue AgentJail::MemoryError
# handle OOM
rescue AgentJail::FilesystemError
# handle filesystem violation
rescue AgentJail::SandboxError => e
logger.error("Tool call raised #{e.original_class}: #{e.}")
logger.error(e.original_backtrace.join("\n"))
end
How it works
AgentJail.run { block }
│
├── opens result_pipe (r, w)
├── fork → Child process
│ │
│ ├── closes r
│ ├── setrlimit (RLIMIT_AS + RLIMIT_CPU)
│ ├── Landlock / sandbox_init (if supported)
│ ├── runs block
│ ├── marshals result → writes to w
│ └── exit!(0)
│
├── closes w
├── starts monitor thread (wall-clock timeout → SIGKILL)
├── reads result_pipe until EOF
├── Process.waitpid2(child_pid)
├── kills monitor thread
└── unmarshals result OR raises TimeoutError/MemoryError
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.
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.
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.
Known sharp edges
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.
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.
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.
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.
MRI only: fork is not available on JRuby or TruffleRuby. Platform.fork_supported? returns false on these runtimes and on_unsupported kicks in.
Requirements
- Ruby >= 3.2.0
- Linux kernel >= 5.13 for Landlock filesystem restrictions (kernel detection is automatic)
ffigem (the only runtime dependency)
Contributing
Bug reports and pull requests are welcome at https://github.com/jibranusman95/agent_jail.
From the same author
| Gem | What it does |
|---|---|
| http_decoy | A real Rack server that runs inside your RSpec tests — test HTTP contracts without WebMock stubs |
| llm_cassette | Streaming-aware cassette recorder for LLM calls — record once, replay fast |
| webhook_inbox | Transactional webhook inbox for Rails — deduplicate, replay, inspect |
| promptscrub | Bidirectional PII redaction middleware for LLM calls |
| turbo_presence | Figma-style live cursors and presence in Rails with one line |
License
MIT — see LICENSE.