agent_jail

Run LLM-generated tool calls in a sandboxed child process — timeout, memory limit, filesystem restrictions.

CI Gem Version Downloads License: MIT


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 exceptionsTimeoutError, 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.original_message}")
  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)
  • ffi gem (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.