What I learned building a guardrail engine for an agent that runs my house — and how the same engine bolts onto an AI gateway.
This year I gave an LLM agent the keys to my house. Battery schedules, climate...
This year I gave an LLM agent the keys to my house. Battery schedules, climate setpoints, calendar, a view of my bank transactions. Not as a demo — as the system that actually runs things, every day, while I'm at work.
Which meant I had to answer a question most agent frameworks politely defer:
what happens when the model is wrong?
Not wrong in the benchmark sense. Wrong at 3 a.m., wrong about my heating in February, wrong with my money. The moment an agent can act, hallucination stops being a quality metric and becomes a physical event. And the standard answer, "we prompt it to be careful," is not an answer at all, for a reason worth stating plainly:
An instruction is not a constraint. To a language model, your rule is just more text that makes certain continuations more likely. It tilts a probability distribution; it cannot clamp one. A contract with a compiler is enforced. A contract with a model is strongly suggested. If your safety story lives in the
prompt, you don't have a safety story. You have a safety mood.
So I built the enforcement outside the model and ran it for months against real consequences. This post is the design that survived, the code is open source, and the last section shows the same engine governing calls inside LiteLLM, because the pattern turns out not to care whose door it guards.
One door
Nothing in my system acts directly. The scheduler, the YAML rules, the LLM agent, even my own thumb on a UI button — all of them can only construct an ActionRequest and submit it to one executor, which is the only code in the system permitted to call a connector. There is one door, structurally. An agent cannot bypass a checkpoint that owns the only road.
This sounds obvious. Almost no framework does it. In most agent stacks, authorization lives in the prompt ("only use this tool when...") or in scattered per-tool checks, which means it lives nowhere the model can't route around.
Autonomy is tiered, per action — not per agent
Every action type carries a tier in a policy table (data, not code):
- Tier 0 — observe. Reads. Always allowed, even during an incident — especially during an incident.
- Tier 1 — auto-execute. Reversible, low-cost, in-bounds. Lights, climate within limits, battery schedules.
- Tier 2 — auto-execute under caps. Rate limits and cumulative euro budgets, enforced by the engine, not trusted to the caller.
- Tier 3 — propose-and-confirm. Everything irreversible, everything over cap, everything involving money — and everything unknown.
That last clause is the first hard rule: default-deny. An action type not in the policy table isn't an error and isn't a pass. It resolves to Tier 3 and a human gets asked. Novelty is never trusted; it is escalated. When my agent invents a new way to be helpful, the system's response is "interesting —
ask the human."
The rule I'd defend in front of any safety review
Here is the design decision I'd keep if I had to throw away all the others:
An action with no registered undo cannot be auto-executed. Reversibility is not a feature. It is the precondition for autonomy.
Every Tier-1 policy must declare a compensating command (restore the previous setpoint, cancel the schedule) at registration time. The policy loader refuses to boot if one is missing. And the executor checks again at runtime: an auto-tier action without a reversal is silently demoted to propose-and-confirm, with the reason logged. If it can't be taken back, a human signs off. By definition, not by vibes.
Executed actions then surface a one-tap undo for fifteen minutes, and the undo itself is submitted through the same pipeline, as a new request marked as a reversal of its parent. Even regret goes through the front door.
Order is the design
The executor is a small deterministic state machine, and the sequence of its checks encodes every priority the system has. Out of order, the same checks would produce a subtly broken machine.
The kill switch is checked first — before policy lookup. If it's engaged, every actionable tier clamps to propose-and-confirm. Reads stay exempt (you want eyes during an incident), and one edge case took me an evening of thinking: an already-approved action arriving while the switch is engaged is
blocked without creating a new approval — otherwise the block would spawn an approval, whose approval would be blocked, which would spawn an approval. The panic button outranks everything, including prior human consent.
Bounds are validated before a human ever sees a proposal. Climate 17–23°C, battery power within hardware limits, enums closed, unknown parameters rejected. The point isn't just stopping the model. It's that the approval screen becomes trustworthy: a human deciding "should this happen?" should
never also have to catch "wait, is 45 degrees insane?" The human decides whether. The machine has already decided whether it's sane.
Dry-run is resolved before cap accounting, for a one-line reason that took me embarrassingly long to see: a rehearsal must not spend a real budget. Every new action type starts in dry-run — two weeks of "would have executed" in the log before it's allowed to touch anything. You get to watch what your agent
would have done before it does it. I recommend this more than any other single feature.
Caps are reserved inside the deciding transaction. SQLite, BEGIN IMMEDIATE, check-and-reserve under the write lock, so two concurrent requests can never both fit under the last slot of a daily cap. Boring database discipline, absent from every agent framework I've read.
Execution is two-phase. Transaction A decides, reserves the cap, and records intent in the audit log. Then the connector call — the network I/O that actually flips the switch — runs outside any lock, under a hard timeout.
Then transaction B appends the result. A hung smart-plug API can't hold the engine's database hostage, and a crash between phases leaves an honest "intended, unconfirmed" row instead of a lie.
And the audit log is append-only. Decisions, results, denials, dry-runs, kill-switch blocks — typed reason codes, never updated in place. When I want to know why the heating did something weird on Tuesday, the answer is a query, not an archaeology project.
What the field has, and what it calls unsolved
I built this for one house and one user, so I read the current agent-security literature with some amusement. The gap analyses say agent authorization still lacks: enforcement in infrastructure the agent cannot bypass (rather than in
prompts); decision types beyond allow/deny — defer, step-up; runtime controls rather than design-time configuration.
The state of the art is worth naming precisely, because it's better than the think-pieces suggest and still structurally short. LiteLLM — the most widely deployed open-source AI gateway — now ships real MCP permission management:
server-level access per key, team and org, tool allow/block lists, even allowed parameter names, with a permission hierarchy where the most restrictive level wins. That's a genuine access-control system. It is also, categorically, static allow/deny. There is no defer (a call that waits for a
human), no dry-run, no undo, no kill-switch semantics, and the parameter control stops at names: the gateway can say "this tool takes amount_eur," but not "amount_eur must be at most 500."
That missing list is this engine's feature set. One door is the bypass answer. Tier 3 is defer as a first-class outcome, and step-up authentication is a field in the money policy (which is Tier 3 permanently — some tiers should never be earned back by good behavior). The kill switch, dry-run windows and cap exhaustion are all runtime state. I don't think I'm cleverer
than the people building agent platforms. I think I had the advantage of consequences. It's remarkable how quickly "the model is usually right" stops being an architecture when the model can open your curtains at 3 a.m.
What I don't have, and haven't seen anyone ship: session-aware trust — an authorizer that remembers what the agent has done this session and degrades its autonomy accordingly. That's the next idea on my list.
The door, installed on other people's doorways
After extracting the engine from my house I did the refactor the
authorization world would call overdue: I split the judge from the bailiff.
The decision half — the ordered checks, the cap reservation, the intent row in the audit log — is one public function (decide_and_reserve); the receipt is a second one (report_result). The in-process executor is just the two composed around a connector call. Which means the enforcement can now live anywhere, because the judgment doesn't move.
First installation: a Model Context Protocol proxy. MCP is where agent tool-calls are converging, and the proxy sits between any MCP host and any stdio tool server, invisible to both. Every tools/call becomes an ActionRequest through the full pipeline. From the demo, verbatim:
3) Out of bounds: denied by the proxy, never reaches the tool:
ERR niyam: 'set_thermostat' denied (reason: bounds — param
'temperature'=30 above max 23.0). The call was not forwarded.
4) Money is Tier 3 — proposed, not forwarded:
ERR niyam: 'send_payment' requires approval (tier 3;
approval_id=1). A human can release it.
5) A human approves — only then is the call forwarded:
ok Sent €49.99 to webshop (simulated).
6) An unknown tool default-denies to a human:
ERR niyam: 'delete_everything' requires approval
(reason: default_deny; approval_id=2).
Point your agent at the proxy instead of the server, write a policy file, and an agent you don't control is suddenly governed by rules you do.
Second installation: inside LiteLLM itself. LiteLLM loads custom guardrails and calls async_pre_call_hook before every call — including, usefully, call_type="call_mcp_tool" for its own MCP gateway. That hook is a ready-made enforcement point, so the adapter is small: build an ActionRequest from the call, consult the engine, obey.
class NiyamGuardrail(CustomGuardrail):
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
if call_type == "call_mcp_tool":
self._decide(f"mcp.{data['name']}", data.get("arguments", {}))
elif call_type in ("completion", "acompletion"):
self._decide("llm.completion", {"model": data.get("model", "")})
return data
Running the self-test against the real LiteLLM class, no proxy required:
completion gpt-4o-mini -> ok (permitted, audited)
completion gpt-4o-experimental -> BLOCK denied (bounds — model not in whitelist)
call_mcp_tool get_weather -> ok (permitted, audited)
call_mcp_tool send_payment -> BLOCK requires human approval (approval_id=1)
call_mcp_tool send_payment €5000 -> BLOCK denied (amount_eur=5000 above max 500.0)
call_mcp_tool delete_everything -> BLOCK requires approval (default_deny)
Six lines, and the gateway now has the four decisions it was missing: defer with an approval id, value-level bounds, default-deny for unlisted tools, and an audit row with a reason for every verdict — on top of the ACLs it already does well. The two systems compose: LiteLLM's key/team hierarchy decides who
may ask; the engine decides what may happen.
Take the pattern, not necessarily the code
The engine is open source (Apache-2.0) — SQLite, single process, deliberately boring; the 58-test suite is the release blocker, one demo walks every mechanism end to end with zero external dependencies, a second drives the MCP proxy from an agent's-eye view, and the LiteLLM adapter self-tests without a
proxy. But the code is the smaller half. If you're building anything agentic, the portable part is the checklist, in order:
One door. Default-deny for the unknown. No undo, no autonomy. Kill switch before policy. Bounds before any human sees a proposal. Rehearsal before budget. Caps reserved race-free. Two-phase execution. Append-only audit.
The model proposes. The policy layer disposes. Everything else is
implementation.
The engine: https://github.com/shamiksaharcciit-oss/niyam. It came out of a personal control plane that has run my home since July 2026; the domain modules stayed home, the engine and its tests are public. If you're building agent governance and see something wrong or missing, the issue tracker is
open — that's what it's for.
Top comments (0)