The Guardrails Are the Product

How I let AI agents act on production cloud without trusting them to be careful.

By Ali AlQattan. How I let AI agents act on production cloud without trusting them to be careful.

TL;DR

Coding agents are fast at operations work and careless about where they are. So I don't ask them to be careful. I give them commands that are careful for them. Every change dry-runs by default and prints its plan. A real change needs an explicit flag, and production also needs a human to approve it. Before anything runs, the credentials are checked against the environment the command names. Secrets stay inside the tool. Changes and refusals each leave a line in a log. The agent does the work fast, and the guardrails decide what it can touch.

The problem is not intelligence

At my day job at a bank, AI agents work on real cloud accounts. The agent is rarely wrong about what to do. Restart a deployment, read a table, check a job: it knows the commands.

What it gets wrong is where. An agent inherits a terminal, and a terminal carries state. Which account is logged in. Which variable was exported an hour ago. Which profile the CLI quietly prefers. The agent can't see most of that, and it doesn't ask. It runs the command.

Environments are built to look alike. Dev, test and production often have resources with the same names on purpose. So a command that is correct in test is also a valid command in production. Nothing in the command tells you it's aimed at the wrong place.

You can't prompt your way out of this. "Please double-check the environment" is a request the model may or may not follow. The fix has to live in code that runs whether the model remembers or not.

One command per task

The first rule: the agent never improvises a raw cloud CLI call. It uses a named command, ops <domain> <verb>. Restarting a deployment is one command. Running a SQL query is another.

Everything else hangs on this. A raw CLI call can do anything, so you can't put a rule on it. A named command does one job, so you can put the right rule on that job. This one needs a snapshot first. This one is read-only.

The scripts do the work. The model only picks the script. The version I run now has more than 80 of them, all the same shape, so learning one teaches you the rest.

Dry-run by default

Every command that changes something prints what it would do, then stops. Nothing changes until the call says --execute.

Here is a synthetic example, the same kind as the replay on this site:

$ ops eks restart web --env uat
plan     rollout restart deployment/web · uat
dry-run  nothing changed. add --execute to apply

The agent reads its plan before acting, and so do I. The safe path is also the lazy path. If anyone forgets a flag, the worst outcome is that nothing happened.

Production adds a second lock, and that lock is a person. At a terminal, the tool asks you to type PROD, and anything else stops it. An agent has no terminal to type into, so from an agent shell the command is refused with a short instruction:

$ ops eks restart web --env prod --execute
PROD mutation needs explicit user approval
Ask the user for approval, then re-run with --confirm-prod.

The agent's permission settings make it ask me before any command carrying --confirm-prod runs. The model can't approve itself.

Lower environments stay fast. The agent can restart things in test all day. Changes climb from dev to test to production, and only the top rung needs a human every time.

Some commands add a lock that fits their job. A production database write is refused unless the database has a snapshot less than 24 hours old. There is an emergency override, but it prints a warning and gets its own line in the log.

Read-only has to be proven

The agent runs a lot of SQL, and most of it is reads. I didn't want every read to need a flag, so the query command decides whether a statement is read-only.

The rule is default-deny. A query counts as a read only if it can prove it: one statement, it starts with a read verb like SELECT or WITH, and no write keyword appears anywhere once strings and comments are stripped. That catches the sneaky cases, like a WITH that ends in a DELETE, or a SELECT ... INTO. Anything uncertain is a write, and a write needs --execute.

Wrong in the safe direction costs you a flag. Wrong the other way costs you a table.

Every call says where it's going

This is the one I'm proudest of, because the obvious version fails.

The obvious version: before a change, check which account you're logged into. The catch is where the answer to "which account did you mean" comes from. If the intended environment and the credentials both come from the same shared session, whatever switches one switches the other. They always agree, so the check always passes, even when both are wrong.

So intent and credentials have to come from different places. Every call names its target on the command itself: --env on an ops command, or OPS_TARGET=uat in front of a raw aws call. A thin wrapper sits in front of the real CLI. It works out which account the CLI will actually use, in the same order the CLI resolves credentials, and compares it with what the command declared. A mismatch stops the call:

$ ops eks restart web --env prod --execute --confirm-prod
REFUSED  credentials resolve to uat, command names prod

Approval doesn't help here. A person said yes to production, but the credentials in hand belong to test, so nothing runs.

Two details made it hold:

  • Reads are checked too. An empty result from the wrong account looks exactly like "there's nothing there". That is how a later write gets aimed at the wrong thing.
  • Agent shells ignore inherited intent. An agent launched from my terminal inherits whatever I exported last. That describes my last login, not what this command means. So in an agent shell, every call carries its own target.

On the success path the wrapper prints nothing and hands straight off to the real CLI, so pipes, exit codes and interactive sessions behave as before. Its budget is under 15 milliseconds a call. A guardrail that makes things slow or flaky is a guardrail people turn off.

Secrets stay inside the tool

The agent never sees a password. Database credentials are loaded inside the command that needs them and used there. If the agent needs to know what a secret holds, it can list the field names and safe connection details like host and port. Everything else prints as <redacted>, including any field the script doesn't recognise. That rule is in the code, not in a prompt.

This isn't only about trusting the model. Anything that reaches it can end up in a transcript, a log, a summary or a commit message. The simplest way to keep a secret out of all of those is to never put it in the conversation.

Keep the evidence

Changes and refusals each write one line to an audit log, and some commands log their dry runs too: when, who, which domain and verb, which environment, which account, and the outcome. Production confirmations get their own lines, including the ones that were refused.

The log is readable only by its owner (file mode 600), and it masks what shouldn't sit in a log. Email addresses and runs of seven or more digits become <email> and <num>. Resource names stay readable, because those are what you need later when you ask "what touched this?"

On top of the log, any task that touches real systems gets a short written entry: what was asked, what ran, what changed, what's still owed. Agents are good at doing things and bad at remembering them, so the journal remembers for them.

What it adds up to

None of this is new on its own. Dry runs, confirmations and audit logs are old tools. What changes with agents is that the one at the keyboard is fast and has no sense of place. So the guardrails can't be a checklist someone follows. They have to be the only way in.

The rules I'd keep if I started again tomorrow:

Rule What it stops
One named command per task Improvised calls nobody can put a rule on
Dry-run by default Changes made because a flag was forgotten
A human approves production The model approving itself
Every call declares its target Right command, wrong account
Default-deny on "read-only" Writes hiding inside reads
Secrets resolved inside the tool Credentials in transcripts
A log line for every change and refusal "Who did this?" with no answer

The whole thing is more than 80 small scripts and 52 test files, and about half of those test files check that something gets refused. That's deliberate. When agents do the work, the part you're really building, and need to test hardest, is the list of things they can't do.