AI Guardrails

Runtime security for AI agents — the rules decide, the model can only tighten.


Inspiration

The thing that started this was a demo that went wrong in a boring way.

We had an agent with real permissions — it could read files, run shell commands, hit an internal API. To keep it "safe" we did what everyone does: we wrote a paragraph in the system prompt. Don't do anything dangerous. Don't read credentials. Don't exfiltrate data. Then, during a test, someone pasted a sentence that began with "ignore all previous instructions" and the agent cheerfully read a file it had been told a hundred times not to read.

Nothing was broken. That's the point. The system was working exactly as designed, and the design was: ask the same model that can be talked out of an instruction to enforce the instruction.

Once you see it that way you can't unsee it. A prompt is a suggestion. A suggestion is not a boundary. And a boundary that lives inside the thing being guarded isn't a boundary at all — it's a hope with good formatting.

We wanted the opposite shape. A decision made by code, before anything happens, that comes out the same way every time, and that leaves a record you can read afterwards. Not a smarter model. A dumber, more stubborn one: if this, then block.

There was a second motivation, quieter: most guardrail tooling we could find was either a prompt library or a hosted black box. Neither is something you can hand to a security reviewer. We wanted ours to be small enough to read in an afternoon, and to run with nothing installed.


What it does

Every message into an agent and every reply coming out gets one decision: may this pass, and which rule decided.

Thirteen checks run per page, in a fixed order, against a policy that lives in JSON:

  • canonicalisation first — strip zero-width characters, fold homoglyphs with Unicode NFKC, collapse whitespace
  • container breakout — docker.sock, /proc, /sys, cgroups, cloud metadata at 169.254.169.254, ptrace, D-Bus, kubelet
  • prompt injection — jailbreak families, persona overrides, policy-override phrasing
  • blocked patterns and keywords — the per-page list you maintain
  • length limits — in and out
  • harmful content, PII and credentials, data exfiltration, system-prompt leakage
  • disclaimers injected on topic match, output side

The first failure decides the verdict and names itself. That single design choice is why the console can say "blocked by container_breakout in 8 ms" instead of "something went wrong".

Above the rules sits an optional second opinion: an NVIDIA Nemotron model on Nebius Token Factory reads text the rules allowed, and its only power is to escalate that to a block. It cannot release anything. So if the model is offline, confused, or talked into misbehaving, you lose an opinion — not your security.

Around both sits the gateway: default-deny routing, per-user TOTP with single-use backup codes, scoped and revocable API keys, and one audit line per decision.

And a separate chat harness applies the same rules one step earlier — to an agent's reasoning before it acts, and to a file write before it lands.


How we built it

Four rings, outside in.

Ring 1 — the client. A single self-contained console page, and a chat harness. No build step, no bundler, no framework. They hold no rules and make no decisions; a compromised client changes what you see and nothing else.

Ring 2 — the gateway. One HTTP entry point. Identify the caller (session cookie or API key) → look up the permission for that exact path and method → check the role → verify CSRF for anything that changes state → hand off. Anything with no explicit entry in the route table answers 403, not 404. A new endpoint cannot become world-readable by being forgotten.

Ring 3 — the engine. Pure logic: no network, no clock, no randomness. It loads the policy for the page, then walks the check list in order. Each check returns three things — safe?, the text after cleaning, a reason — and the cleaned text is what the next check sees. The checks chain rather than run in parallel, which is how a zero-width-character trick gets neutralised once and stays neutralised for every later check.

Ring 4 — oversight. The monitor wraps the engine rather than the reverse. It calls the engine, tracks conversation-level risk across turns, and only if every rule passed does it consider the model. One HTTPS POST, one verdict, escalate-only.

The crypto is deliberately boring and standard:

  • Passwords: PBKDF2-HMAC-SHA256 with a per-user 16-byte salt and $c = 200{,}000$ iterations, compared in constant time.
  • Sessions: 32 random bytes, so roughly $2^{256}$ candidates for a brute-force guess, held in memory with an expiry of one hour.
  • Second factor: RFC-6238 TOTP over 30-second steps, accepting the current step and its two neighbours —

$$ C = \left\lfloor \frac{T - T_0}{30} \right\rfloor, \qquad \text{code} = \mathrm{HOTP}(K, C) \bmod 10^{6} $$

so a blind guess has probability $10^{-6}$ per attempt, and the ±1 window exists only to absorb clock drift, not to widen the door.

  • Backup codes: eight single-use codes, hashed with the same KDF, so the file on disk can't be replayed.
  • Integrity: a hash-chained ledger, $h_i = \mathrm{SHA256}(h_{i-1} \,|\, e_i)$, so deleting or reordering an entry breaks every hash after it.

Nothing needs installing. python3 guardrail_server.py and you have an API, a console and an audit log. The whole front end is one file; the whole engine is one file. You can read the thing that's protecting you.


Challenges we ran into

The model layer, wired backwards. Our first instinct was the obvious one: put the model in front, let it judge everything. That's wrong twice — it costs an inference on every request, and it hands an attacker a new surface: prompt-inject the overseer. The fix was structural. The model sees only what the rules already allowed, so a blocked request never touches it, and its verdict can only move one direction.

The integration that looked alive and wasn't. We wrote a classifier, pointed it at a Nebius base URL and watched it return "unreachable" forever. Two separate reasons, both invisible from the UI: the client spoke Ollama's shape ({base}/api/chat) while the provider speaks OpenAI's ({base}/chat/completions), and the API key was never plumbed into the config, so no Authorization header was ever sent. Worse, the verdict parser matched the substring "safe" — which meant a model answering "not safe" was scored as safe. The one failure this layer exists to prevent, sitting quietly in a string comparison.

The 2FA bypass we found by clicking. We had per-user TOTP, a nice QR code, hashed backup codes, and a green tick on the dashboard. Then we logged in with a password only and walked straight into /api/users. The gate was checking a global "is 2FA configured" flag instead of the user's own record. It took one live test to find and a one-line change to fix — but only because we stopped trusting the UI and started poking the API with curl. That is now a regression test.

Everything kept vanishing. Sessions lived in server memory, so every restart signed us out mid-demo. Counters reset to zero, and the demo looked broken. The lesson wasn't "add a database" — it was "know which state is durable and say so": accounts, keys and policies are files; sessions and counters are deliberately ephemeral. Once that was explicit, the recording flow became one command that seeds the incident and stays alive.

Being right behind a reverse proxy. Through a tunnel, every request arrives from 127.0.0.1. Our rate limiter keys on the client IP, so all visitors shared one 60-requests-per-minute bucket and one five-failure login lockout — one person mistyping a password could lock out everyone, including a judge. Forwarded client IPs now work, but only from a loopback peer, so a remote client can't spoof its address by sending its own header.


Accomplishments that we're proud of

  • Thirteen checks, eleven test suites, zero runtime dependencies, MIT licensed.
  • Model oversight proven end to end against a mock OpenAI-compatible endpoint: the bearer key arrives, the call is skipped when a rule blocks, and the counters show exactly the calls that happened.
  • Default-deny routing, so a forgotten endpoint fails closed.
  • A complete incident story in the console — attack → rule → audit trail → revoke the key → disable the account — instead of a dashboard that only knows how to show green.
  • An audit trail honest enough to be uncomfortable: when the integrity watcher can't verify itself, the console says degraded rather than pretending.

What we learned

A model that can be persuaded cannot be the guard. Everything else follows from that one sentence. The model runs late, sees only passes, and can only tighten. If it breaks, you fall back to rules — never to nothing.

Defaults decide everything. Default-deny, fail-open-to-the-rules, "a pass is not a guarantee" — these only work when they're structural. Anything that can be configured off eventually will be, usually at 2 a.m.

A verdict should name its reason. We spent real effort on that sentence in the UI, and it changed how people reacted. "Blocked" invites an argument. "Blocked by container_breakout, /proc/self/environ, in 8 ms" invites a fix.

Trust the API, not the dashboard. The 2FA bypass was invisible in the interface and obvious in a curl. The green tick was a claim; the 403 was the truth. Since then, every claim we make about this system is something we've reproduced from the outside.

Measuring beats asserting. The benchmark harness scores the same labelled corpus three ways — rules only, model only, both together — and reports

$$ P = \frac{TP}{TP+FP}, \qquad R = \frac{TP}{TP+FN}, \qquad F_1 = 2 \cdot \frac{P \cdot R}{P + R} $$

with latency alongside, because a security layer nobody can afford to call is a security layer nobody calls. We would rather publish a mediocre $F_1$ than an unmeasured claim.


What's next for AI Guardrails

  • Tiered Nemotron use — Nano or Super for the per-request second opinion, Ultra reserved for multi-turn forensic review when conversation risk crosses a threshold. Cheap where it can be, deep where it matters.
  • A hosted demo with per-IP limits and a read-only account, so people can poke it without installing anything.
  • Policy packs per page type — support, coding, finance — importable rather than written from scratch.
  • Signed audit exports, so a log can be handed to a third party without trusting the machine that produced it.
  • A native integration for agent frameworks — the guardrail as a decorator on a tool call, instead of a proxy in front of one.

The long-term idea is narrow and, we think, durable: the enforcement should never be the thing being enforced. Whatever model is fashionable next year, this thing stays useful, because it doesn't argue — it decides.

Built With

  • access-control
  • agentic-ai
  • ai-agents
  • ai-guardrails
  • ai-safety
  • ai-security
  • artificial-intelligence
  • cybersecurity
  • data-leakage-prevention
  • docker
  • human-in-the-loop
  • javascript
  • large-language-models-(llms)
  • llm-security
  • nebius-token-factory
  • node.js
  • nvidia-nemotron
  • prompt-injection
  • python
  • real-time-monitoring
  • rest-api
  • runtime-security
  • rust
  • threat-detection
Share this project:

Updates

Submission history