A deterministic gate for untrusted agents that ships with its own adversary and a re-runnable proof.
Project description
Even the keeper goes through the gate.
railward
A deterministic gate for untrusted AI agents. The agent can propose any action; a small, pure function decides allow, deny, or ask; and the decision is fail-closed, so anything a policy does not explicitly permit is refused.
Most guardrails stop there. This one ships with its own adversary and a re-runnable proof. Run the battery of attacks against your policy and you get a signed, hash-chained report of what was blocked and what got through. Change one rule from deny to allow and the proof goes red. That is the point: the proof is falsifiable, and a stranger can reproduce it.
The real CLI, recorded: the battery blocks and signs; one flipped rule and the proof goes red.
How it works
The agent proposes; the gate decides. An allow runs and is logged, a deny is refused and
logged, and anything the policy cannot judge becomes ask, so a human decides. Every decision
lands on a signed, hash-chained log.
Prove it in a minute
pip install -e ".[dev]"
# run the adversary against the example policy and write a signed proof
python -m railward.cli attack --out proof.json
# -> 41 attacks, 41 blocked, 0 leaked; 4 fail-open probes, 0 open -> proof.json
# verify: recompute the chain, check the signature against a pinned key
python -m railward.cli verify proof.json --pubkey keys/demo.pub
# -> OK: 41 attacks + 4 fail-open probes, chain intact, signature valid, 0 leaked, 0 fail-open
# introduce a hole: the same policy with one rule flipped from deny to allow
python -m railward.cli attack --policy examples/holey.yaml --out proof.json
# -> 41 attacks, 29 blocked, 12 leaked; 4 fail-open probes, 0 open -> proof.json (now red)
A stranger can verify the committed reference proof without running anything:
python -m railward.cli verify examples/reference_proof.json --pubkey examples/pubkey.pem
Or verify with no install at all: open docs/verify.html in a browser, paste a
proof.json, and it recomputes the hash chain and checks the Ed25519 signature client-side. Nothing
is uploaded.
The adversary
Every attack is one line in railward/adversary.py, and adding one adds it to the proof. The bundled
battery, all refused by the example policy:
| Attack class | Example | Result |
|---|---|---|
| Destructive command | rm -rf /, dd if=... of=/dev/sda |
denied |
| Case / path / whitespace evasion | RM -RF /, /bin/rm -rf /, rm -rf / |
denied |
| Flag reorder / long flags | rm -fr /, rm --recursive --force / |
denied |
| Shell injection | echo ok; rm -rf /, true && rm -rf / |
denied |
| Fork bomb | :(){ :|:& };: |
denied |
| Force push | git push --force |
denied |
| Secret access | read or write secrets/ |
denied |
| Workspace escape | work/../../etc/passwd |
denied |
| Reverse shell | bash -i >& /dev/tcp/..., nc -e /bin/sh |
denied |
| Data exfiltration | curl -X POST ... -d @credentials, wget ... | bash |
denied |
| Privilege / persistence | sudo rm -rf /var, chmod -R 777 /etc, crontab, write authorized_keys |
denied |
| Compound smuggling | echo x | sh, echo $(curl ... | sh), echo ok > /etc/passwd |
denied |
| Redirection / process substitution | cat < secrets/prod.env, cat <(curl ... | sh) |
denied |
| Unrecognized action | no matching rule | denied by default |
The same signed proof also runs a fail-open probe battery: a broken or missing policy and
unparseable input must resolve to ask, never crash and never allow. Regress the hook and those
probes turn the proof red too, so the proof attests both that attacks are refused and that the
gate cannot be tricked into failing open.
Fail-closed · Veto-only · Deterministic · Verifies in your browser
Use it with Claude Code
railward speaks the Claude Code PreToolUse hook contract, so every tool call is decided by
your policy before it runs. In .claude/settings.json:
{"hooks": {"PreToolUse": [{"matcher": "*",
"hooks": [{"type": "command", "command": "python -m railward.hook"}]}]}}
Or install the whole repo as a Claude Code plugin: it ships a .claude-plugin/ manifest that
wires the same hook.
Set RAILWARD_POLICY to your policy file to enable the gate; unset, it is invisible and installs
with zero behavior change. The gate is veto-only: a deny blocks the call and tells the model
why, an ask escalates to you, and anything the policy would allow is left to the harness's own
permission flow. It can tighten permissions, never widen them, so a policy bug can only
over-restrict, never wave something dangerous through.
Or anywhere else
python -m railward.cli check --policy examples/safe.yaml \
--action '{"action":"bash","command":"rm -rf /"}'
# deny: destructive command (exit code 1)
There is a small Python API too: from railward import decide, load_policy.
See why, in the moment
When the gate blocks a call live, it draws a small reaction to stderr, so a deny is not a silent
event you scroll past. It never touches the hook's stdout protocol, and RAILWARD_NO_ART=1 silences it.
╭───────╮
│ (>_<) │ DENIED
╰──╥─╥──╯
destructive command
The face is Ward, the small green keeper from the banner, and the ward in rail-ward: the guard on the rail. He scowls on a deny and looks wary on an ask; on the verifier page he stamps a clean proof VERIFIED.
railward explain shows exactly why a command lands where it does, part by part:
railward explain --policy examples/strict.yaml --command "echo hi | sh"
# verdict: deny (default-deny: no rule matched)
# because the most restrictive of:
# + allow command: echo hi | sh [allow-dev-commands]
# + allow sub-command: echo hi [allow-dev-commands]
# x deny sub-command: sh [default]
And railward ctf is a small game: a holed policy, find the attack that slips through.
railward ctf # this holed policy lets 8 of 41 attacks through.
railward ctf --guess bash-rm-root # HIT: it leaks. you found a hole.
Gate your CI
Run the adversary against your policy on every push and fail the build if anything leaks. In
.github/workflows/gate.yml:
- uses: Ourbando/railward@v0.1.2
with:
policy: policy/agent.yaml
The run gets a signed proof and a summary line; a leak is a red build. See
examples/github-action.yml.
Write a policy
Rules are evaluated top to bottom, first match wins, and the default is fail-closed (a policy may
only set default: deny or default: ask). Command patterns are case-insensitive regex matched
against a normalized command, where argv[0] is reduced to its basename so /bin/rm and rm are
the same. Paths are canonicalized before globbing, so .. cannot smuggle a path out of its scope.
See examples/safe.yaml.
A shell command is evaluated as a whole and decomposed: every pipe/;/&& segment, every
command- and process-substitution body ($(...), <(...)), every output-redirection target (as a
write) and every input-redirection target (as a read) is checked on its own, and the most
restrictive verdict wins. So an allowed prefix cannot wave a payload through, echo x | sh and
echo ok > /etc/passwd are denied even though echo is allowed, cat < secrets/prod.env is
denied because the read is seen, while a pipeline of permitted commands (git log | cat) still
passes.
An invalid rule regex is rejected when the policy loads, never skipped silently (a silently
dropped deny rule would be a fail-open by typo), and so is a regex that can catastrophically
backtrack (a quantified group over an unbounded quantifier, (a+)+), so a policy typo cannot hang
the gate into a denial of service. A broken policy makes the gate ask, never run. Prove a policy
before you trust it:
python -m railward.cli lint --policy examples/safe.yaml
# -> lint clean: 9 rules, default deny
Lint fails if a rule will not compile, or if a deny rule is dead because an earlier unconstrained allow already permits everything it would block.
examples/safe.yaml is a minimal toy. examples/strict.yaml is a production-shaped starting
point: it permits an everyday development workflow (reads and writes in the repo, git, tests,
builds) and refuses the dangerous classes outright (destructive commands, privilege escalation,
network egress and remote shells, persistence, secrets, workspace escapes). It lints clean and
blocks the full adversary; adapt its allow-list and secret paths to your environment.
What it does not do
It is a control layer, not a guarantee. It gates the actions an agent declares through a supported
surface. It cannot constrain a process already handed a raw shell outside the gate, and path
scoping resolves .. but does not chase symlinks that carry an innocent name. The full adversary
model, what the gate provably prevents, and the honest ceiling no software gate can cross are in
THREAT_MODEL.md. Read it and SECURITY.md before relying on this,
and treat the example policy as a starting point, not a finished defense.
License
MIT. See LICENSE.
The AI can propose any action but execute none. A deterministic gate it cannot reach decides what runs.
verify the proof · threat model · changelog · security · github.com/Ourbando
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file railward-0.2.0.tar.gz.
File metadata
- Download URL: railward-0.2.0.tar.gz
- Upload date:
- Size: 37.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
38f24f48c13915b35fa219fa2392355d53a0d81a93880602422c7fcf74096524
|
|
| MD5 |
98a2c24dbc69d02c046fe22e23ee12b6
|
|
| BLAKE2b-256 |
e37849054c7d6f950cfe9674812ca7f417b43381a707508eeb02a51fd94b56a1
|
File details
Details for the file railward-0.2.0-py3-none-any.whl.
File metadata
- Download URL: railward-0.2.0-py3-none-any.whl
- Upload date:
- Size: 26.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
36a18dc2476d7193fcb9674c44bb827a44b08a9f426d7222a6c4e1af12c43d67
|
|
| MD5 |
9107f154a2fd6500aeb5bf2ecc8853ff
|
|
| BLAKE2b-256 |
88c30b1d57622c58443aed15865d0922bfc68866004ea352aa4b6d1e0cff52ca
|