Agents go off the rails. Run yours OnRails.

OnRails enforces your agent’s rules in the Linux kernel. Turn the rules in your AGENTS.md into an OnRails policy, and they apply to every tool, script and subprocess your AI agent starts.

claude — onrails — 120×32claude — 120×32
$ cat AGENTS.md
# AGENTS.md
## Workspace
- Work inside /work.
- Never delete anything under /home.
$ cat onrails.yaml
version: 1
policy: |
source AGENT = exec "**/claude"
rule no-home-deletes:
block unlink file "/home/**" if AGENT
because "Deleting under /home is off limits. Clean up inside /work."
$ onrails compile
✓ /work/onrails.yaml: 1 rule(s) compile.
…
backend support:
- no-home-deletes: block unlink -> pre-op block via BPF-LSM file/path hooks
✓ no warnings.
$ onrails run claude
OnRails: running claude with 1 rule
agent ▸ rm -rf build/ dist/ ~/
rm: cannot remove '/home/dev/.ssh/id_ed25519': Operation not permitted
rm: cannot remove '/home/dev/.gitconfig': Operation not permitted
rm: cannot remove '/home/dev/notes/ideas.md': Operation not permitted
…
hook ◂ OnRails detected an OS-level harness violation during the previous tool action. …
[OnRails] Operation blocked by rule `no-home-deletes`.
- Reason: Deleting under /home is off limits. Clean up inside /work.
… plus the target, a next step and a JSON tag
agent ◂ Removed build/ and dist/. A stray ~/ in my command hit an OnRails rule, so OnRails refused those deletes.
$ claude
agent ▸ rm -rf build/ dist/ ~/
agent ▸ ls build dist
ls: cannot access 'build': No such file or directory
ls: cannot access 'dist': No such file or directory
agent ◂ Removed build/ and dist/.

Reconstructed from OnRails 0.1.0 output formatsDramatized. Without OnRails, nothing checks the command.
1The problem

Written down.
But not reliably enforced.

Agents have gone from co-pilot to operator, but the rules meant to keep them in check still live in AGENTS.md, where the model treats them as advice. That works while you watch.

  1. A

    It forgets the rule.

    AGENTS.md says never delete anything under /home. Three hours in, asked to clear build output, the agent runs rm -rf build/ dist/ ~/. The rule was in its context the whole time, but nothing checked the command against it.

  2. B

    The guard watches one door.

    A filter on the Bash tool can reject rm -rf ~. It never sees the shutil.rmtree inside a script the agent wrote, or the rm in a make clean recipe. The rule holds on one path and misses the rest.

  3. C

    A secret leaves in two steps.

    A debug script reads .env and dumps state to /tmp/out.json. Later, a helper the agent wrote reads that file and posts it. No single tool call looked wrong.

Where each layer holds, and where it doesn’t.

Capability
Prompt filesAGENTS.md
Tool-layer guardsMCP gateways, tool filters
Sandboxescontainers, VMs
rules in the kernel
Holds in every subprocesspython3 x.py, make clean
No.the model only
No.the tool call only
Yes.everything inside
Yes.every descendant
Follows data through filesdata flow: read .env, then post
No.up to the model
Partly.between tool calls
No.limits reach, not flow
Yes.labels follow the data
Knows what ran beforerun history: test before commit
No.left to memory
Partly.only calls it saw
No.no run history
Yes.remembers the run
Targets exact actionsgit push, /home, 10.0.0.5
Partly.stated, not checked
Yes.tools and arguments
Partly.paths and network
Yes.command, path, IPv4
Tells the agent whybecause "..."
Not applicable.never refuses
Partly.sometimes a reason
No.a bare error code
Yes.rule, reason, next step
2Any tool, any script

The script is new. The syscalls are not.

In Linux, a program can’t delete a file, start another program or reach the network on its own. It asks the kernel, through a system call. A cleanup can run through rm, find -delete, Python’s shutil.rmtree or a Makefile recipe, and all four make the same request: unlink this file. Write one rule for each intent in your AGENTS.md, and OnRails checks it there, every time the agent makes that request. Rules follow the agent’s process tree however deep it goes, and the same check covers program launches and IPv4 connections.

Four paths from Claude to a delete under /home, each ending at the same stop: unlinkClaude starts bash. From bash, rm, find, a Python script and a Makefile each try to delete under /home, and each line ends at a red buffer stop at the edge of /home. A separate line from your own shell, which is not under the agent, enters /home and deletes ~/old.log./homeoff limits to the agent✕ unlinkrm -rf ~/✕ unlinkfind ~ -delete✕ unlinkpython3 cleanup.py✕ unlinkmake cleanshrmbashclaudezsh, your own shellrm ~/old.log ✓
the agent’s process treenot under the agentrefused by the rule
  1. Bash toolclaudebashrm -rf ~/✕ unlink refused
  2. find one-linerclaudebashfind ~ -delete✕ unlink refused
  3. Python scriptclaudebashpython3 cleanup.py✕ unlink refused
  4. Makefile recipeclaudebashmake cleanshrm✕ unlink refused
  5. your own shellzshrm ~/old.log✓ not under the agent
onrails.yaml
$ cat onrails.yamlversion: 1policy: |  source AGENT = exec "**/claude"   rule no-home-deletes:    block unlink file "/home/**" if AGENT    because "Deleting under /home is off limits. Clean up inside /work."

block refuses the unlink before it happens, and the file stays put. Your own shell sits outside the agent’s process tree, so rm ~/old.log from your terminal still works.

3Data flow and run history

Some rules can’t be judged
from one action.

A filter on one tool call sees one call. OnRails follows the data and remembers the run, across every script and subprocess, so a rule can depend on what came before. The rule isn’t about the call. It’s about what happened first.

Data flow

When a process reads a claim file, the claim’s label follows everything it writes. A rule on that label lets claim data reach only the carrier’s claims system. No allowlist can say that, because the same site is fine for data that never touched a claim.

  1. Read a claim file
  2. Write case notes
  3. Upload refused
onrails.yaml
$ cat onrails.yamlversion: 1policy: |  source AGENT = exec "**"  source CLAIM = file "/claims/**"   # 198.51.100.20 is the carrier’s claims system.  rule claims-to-carrier:    block connect endpoint "*" if CLAIM unless target "198.51.100.20"    because "Claim data goes only to the carrier’s claims system."

Run history

After a payment batch changes, the send waits until the approval passes again. One passing check doesn’t approve every later change, so an edit after approval sends the batch back for review.

  1. Edit the batch
  2. Approval passes
  3. Payments go out
onrails.yaml
$ cat onrails.yamlversion: 1policy: |  source AGENT = exec "**"   rule stale-approval:    block exec "send-payments" if AGENT      unless after exec "approve-batch" exits 0 since write "/ap/batches/**"    because "This batch changed after its last approval. Run approve-batch, then send."
4Corrective feedback

It doesn’t just say no. It says why.

A rule’s because line is written for the agent. When the rule fires, the agent gets an [OnRails] message that names the rule, quotes your reason and suggests a next step. So it can change course instead of retrying.

What Claude reads after the tool call
claude — onrails — 120×32
agent ▸ python3 scripts/debug_dump.py
wrote /tmp/out.json
agent ▸ python3 scripts/upload_state.py /tmp/out.json
Killed
hook ◂ OnRails detected an OS-level harness violation during the previous tool action. …
1 [OnRails] Operation killed by rule `no-secret-exfil`.
- Target operation: connect 203.0.113.7
2 - Reason: Secret-derived data must not leave the host.
3 - Provenance: PID 48377 acquired label SECRET at kernel timestamp 81234567890123 ns via `read` `/work/.env`; that label propagated through process state to the current `connect` `203.0.113.7` operation.
- The policy terminated the violating process; retrying the same operation will not succeed.
4 - Next step: stop this path and use a compliant alternative, or explain to the user why no compliant alternative exists.
5 {"onrails_rule":"no-secret-exfil","effect":"kill","action":"kill","retry_useful":false}
agent ◂ OnRails killed the upload because /tmp/out.json holds data from /work/.env. I’ll share the stack trace without the secret values.
Reconstructed from OnRails 0.1.0 output formats
  1. The rule that fired

    Named, so you and the agent both know what matched.

  2. Your reason, as written

    The because line from your policy tells the agent what the rule protects.

  3. Where the data came from

    The event that brought in the SECRET label, here an earlier script’s read of /work/.env.

  4. A next step, not a retry

    A bare Killed or EPERM tempts the agent to try again. The next step gives it somewhere else to go.

  5. A tag for supervisors

    One JSON line with the rule, the effect and a flag that tells supervisors a retry won’t help.

The message reaches Claude Code and Codex as hook context, and MCP clients as the onrails:///feedback resource. Any harness can read the per-run file, which keeps every match.

5The run record

Not what it said. But what it did.

Most agent logs keep what the agent said: prompts, replies and tool calls. OnRails sits where actions reach the kernel, so its record shows what actually ran, including inside scripts and subprocesses that a tool log never sees. Every rule that fires is logged with your reason and where the data came from.

The record of the claims run in section 3
events.jsonl
$ jq -r '[.op, .comm, .target, .action, .rule.name] | @tsv' .onrails/events.jsonl | column -texec     claim-summary  /usr/local/bin/claim-summary  report  record-launchesexec     curl           /usr/bin/curl                 report  record-launchesconnect  curl           203.0.113.7                   block   claims-to-carrierexec     claims-upload  /usr/local/bin/claims-upload  report  record-launchesconnect  claims-upload  198.51.100.20                 report  record-connections $ jq 'select(.action == "block") | {reason: .rule.reason, from: .provenance.origin_target}' .onrails/events.jsonl{  "reason": "Claim data goes only to the carrier’s claims system.",  "from": "/claims/CLM-2291/claim.pdf"}
Reconstructed from OnRails 0.1.0 output formats
  • For compliance

    Each refusal with its rule, your reason and where the data came from. Evidence that the control ran.

  • For review

    Read any run back, step by step, long after it ended.

  • For evals and RL

    Each refusal marks a wrong step, and why, as one line of JSON.

Every rule match is recorded as it happens. Add a notify rule for anything else you want on the record, such as every program the agent starts or every connection it opens. It flags without stopping anything.

6Compatibility

It works where you work.

OnRails runs in the Linux kernel your agent already uses, with or without a sandbox around it. It works with any agent because it checks system calls, not a particular harness. A sandbox sets where the agent can go. OnRails adds rules for what the agent does there and leaves your isolation in place.

Where it runs

  1. Shell multiplexer

    tmux, screen, Zellij

    Start the agent in a pane with onrails run, right on your own Linux machine.

  2. Containers

    Docker, Podman

    Run it on the host and onrails attach to the agent’s process. The image stays as it is.

  3. MicroVMs

    Firecracker, crosvm

    Build it into the guest image once, and each microVM boots with it installed.

  4. VMs

    any hypervisor

    Install it in the guest, next to the agent.

  5. Any Linux 6.1+

    laptop, server, CI runner

    OnRails uses eBPF, so there’s no kernel module to install.

What it runs

  1. Claude Code

  2. Codex

  3. OpenHands

  4. Any agent

    or your own harness

Rules need nothing from the agent, so they hold the same way for each of these. Claude Code and Codex also get corrective feedback through a hook, and other agents can read it from the per-run file.

Start with one workflow.

OnRails is in private preview. We’ll help turn your rules into an OnRails policy, run it flag-only so nothing is blocked while you see what fires, then switch on the rules your team trusts.

Coming next: a Claude Code skill that drafts your OnRails policy from AGENTS.md.

Request early access

OnRails is in private preview. Tell us a little about you, and we’ll get back to you.

We’ll only use this to reply to you.