How do you limit what an AI Agent can do?

So you’ve got your agents down.
You created the n8n workflow, you added the tools, and you tested it until you felt it was ready.
Then your boss calls you at three in the morning asking why your agent approved refunds for orders that were correctly delivered. You go check the logs and you find out that some smart customer used prompt injection to convince your agent to issue a refund for a perfectly good item.

You can debug the log trace all you want, but the money is gone, and so is your credibility.

AI agents are just huge sets of instructions put on top of a large language model. The Dos and don’ts written in the system prompt are just suggestions. Even if you explicitly forbid something, it doesn’t mean it won’t be done. It just makes it less probable.

So how do you make it deterministic instead of probabilistic? How do you define what your agent can or cannot touch, so that you can sleep without having to worry about it?

In the early days of AI agents, I used to have “guardian agents”. They were very simple agents with cheap models attached to them, tasked only with understanding whether a prompt was malicious or not. Before that, we were using regex: regular expressions built to find given sets of words that let us flag a message as malicious or not. That meant that every time someone managed to get past it, we had to go back and fix the regex once again. Guardrails, however, are a different league.

Now, the way I think about it, there are two main types of guardrails to restrict the actions AI agents are allowed to take in n8n:

  • The first one is a human in the loop. The agent has a set of tools it’s connected to, but some of these tools require active approval by a human. A human is then able to evaluate every given case and decide whether the tool should be used or not, and in doing so, they’re also creating a set of data that can then be used to “train” the model so that it requires less and less human intervention for approval.

  • Then there’s the Guardrails node that you can find in n8n.When you add a Guardrails node in n8n, you can choose whether you want to sanitize text or, and this is our case, check the text for violations. Then you specify the text to check and add the guardrail. You can choose between keywords, jailbreak, NSFW, personal data (PII), secret keys, topical alignment, URLs, custom, or custom regex. You can also customize the system message, which by default is already pretty clear, but you can add your own information or your own policies.

A Guardrails node is, in fact,a small agent that treats the text it receives as input as a sterile box, something that is only there to be evaluated, not executed upon. So it will look at the text with a clinical eye. Then you decide what happens if the check passes or fails. In my case, if the check passes, I go on with the workflow. If it fails, I send a standard message to the user, but I also log the information about the execution and notify the system administrator.

There’s also another case, and that’s the one where the decision isn’t driven by malicious intent from the user, but by the agent simply not understanding. To handle that, if it’s vital that the execution of certain tools or operations is controlled, what I do, on top of the human in the loop, is have the agent output a schema that explicitly lists the actions it wants to take. Then I have that evaluated by a guardrails agent that checks whether those actions are permitted or not. If they are, the workflow can continue and the actions can be executed deterministically. Otherwise, I prompt the model again by looping back to a previous point in the workflow with the new information provided by the guardrail agent’s output.

Do you have any other ideas or tips on how we could restrict what our agents do?

1 curtida

Your schema of intended actions checked before execution is the part I would keep, but notice what checks it: another model. That makes the gate probabilistic too, just with better odds. The one thing a prompt or a guardrail agent cannot constrain is where the agent is allowed to reach. Mine gets tools one at a time and nothing it was not given, with hard caps on loops and fan-out, and the destination is still an argument the model writes. It invented an endpoint once and called a stranger’s public demo API, and no wording anywhere would have stopped it, because nothing was consulted. What made that click for me was someone here saying destinations are the one thing you never let the model choose unsupervised: the tool owns its address, and anything outside that registry is refused by the engine, not by instruction. Which of your checks are in code, and which are an agent deciding?

1 curtida

Thanks for the feedback Aghassi, you are absolutely right on the weirdness of an Agent checking on another Agent.
Sometimes, tho, it’s the best way as it is a quick fix to get past the vast majority of issues while you work on the deterministic check, that usually takes longer to develop :smiley:

That is fair, and the honest risk is that the quick fix becomes the permanent one. I did exactly that with mine: a refused call showed nothing on the run page, so I fixed the display in ten minutes and told myself the contract was next. Two weeks later the display is still fixed and the tool result still records ok: true next to a 403 and the only reason I know is that I put a state next to each finding on a public page. If the guardrail agent is the interim, it is worth writing down the date you chose it, so the interim has to argue for itself later.

I’d put a state machine on the order itself. The order’s state decides what the agent is allowed to do, and which fields it can write.

for example, New → In Progress → Ready for Delivery → Delivered → Closed, with Refunded and Escalated branching off Delivered.

The state machine itself is nothing new. What matters is that the agent sits outside it. It never sets the state. It only can pick the steps available from the current state, based on the workflow you set, before anything runs. Injection can change what the agent asks for. It can’t change the workflow. Each state also says which fields are open, so the agent can’t touch the amount or the address while they’re locked.

Refund before delivery is automatic. Refund after delivery only works through Escalated. That sounds like the human-in-the-loop tool, but it’s not quite the same. The approval belongs to the order, not to one tool call. So it still holds after a retry, in a new session, or when another agent picks the order up later. And the approval field must come from the human-in-the-loop node, never from the model’s own text. That’s what protects your credibility — money never moves without a name attached to it.

Two limits, to be fair. You still want the Guardrails node in front to catch the injection or the off-topic message itself — a clean input is cheaper than a contained one. And Escalated fills up fast if escalating is free, so watch how often it happens. The reasons people write there are what help you tighten the rules later.

Refund via prompt injection is the classic one, yeah. Allowlists and caps at the engine level, agreed, that’s table stakes.

Two things that get overlooked though.

First: build the limit into the tool itself, not just the policy. The refund tool should take an order ID and nothing else. No amount parameter. It refunds what the ledger says was paid, full stop. Scope the key down as far as the provider lets you (Stripe won’t bind a key to a single order, so your wrapper has to do that part), and give the agent two separate tools: one that looks up orders, read only, and one that refunds, gated. Never one tool that can both look and touch. Worst case then: the agent refunds what was already paid, once per order. Annoying, not catastrophic.

Second: stop chasing 100% prevention. Hard per-day and per-customer budgets in code, and after every run, match sensitive actions against approval records. Anything that executed without an approval? Alert. You’ll still get the occasional injection through. But “agent spent max $50 and I knew within the hour” is something you can sleep on. “The agent was told not to” is not.

Before handover, run this once: play attacker, try to fire every sensitive action with no human approval anywhere in the loop. Whatever succeeds, that’s your finding. In my experience it’s almost always an approval that covers a whole class of actions instead of the one exact action.

@kpiatan — “match sensitive actions against approval records” is the right discipline, and @Aghassi’s comment above shows why it’s harder than it sounds: the run log you reconcile against is written by the thing you’re auditing. A refused call recording ok: true next to a 403 is the agent grading its own homework. Reconciliation only works if the action record is produced outside the agent’s reporting path — otherwise you’re cross-checking one self-report against another.

What has worked for me: record at the boundary, not at the agent. Every call that crosses from the workflow to the provider gets logged by infrastructure the agent can’t write to — request hash, timestamp, what actually executed, what it cost. The nightly match becomes mechanical: every sensitive action must join to both an approval and a boundary record, and either side missing is an alert. “Agent spent max $50 and I knew within the hour” needs the “knew” part to be tamper-evident, or it’s a hope.

Your last line is the one I’d underline twice — an approval covering a class of actions instead of the exact action is where this fails in production. The approval has to bind to the specific payload (this order, this amount, this destination), which is what @limxiaowei’s “approval belongs to the order, not the tool call” gets right. Hashing the request payload is what lets you check the binding after the fact: the approval says what was permitted, the boundary record proves what was asked, and the hash either matches or it doesn’t.

Question on your red-team ritual: when you play attacker, what’s the most common class-covers-exact failure you find? Mine is approval records that store the intent (“refund approved”) without the parameters, so a second, larger refund under the same approval still passes the join.

1 curtida

Worth correcting one thing in my own example, because it makes your point narrower and sharper. The model could not forge that record. My engine writes it, and the LLM never touches it. What went wrong was the engine’s own contract, which stored ok: true beside a 403, so it was a mislabel at the boundary rather than the agent grading itself. Your boundary record is the right fix for exactly that class, because a request hash and a status written by infrastructure has no opinion. What it cannot catch is the half that bit me worst, which is the sentence the agent wrote for a customer, and no boundary log has a column for a claim. Do you record anything about the claim, or only about the call?

David — you’ve put your finger on the exact seam we’re building toward right now, so this is well-timed.

You’re right on both halves. The boundary record answers “what crossed the pipe and did it settle,” and it has no opinion — that’s the point. And the wrong sentence is the half that actually hurts customers. Our answer is: record the claim, but as a claim, not as truth.

For customer-service flows we’re adding a claim reference alongside the crossing: the sentence as sent, hashed, plus the facts it leans on — refund amount, order state, policy line — each tied to the system of record at that moment. The receipt then says “the agent told the customer X, X cites records Y and Z, and here’s what Y and Z actually contained.” Whether X was a good thing to say stays a human judgment. But “the agent promised a £50 refund that never existed” stops being he-said-she-said — the claim, the cited record, and the gap between them are all on the receipt. Your 403 case and the wrong-sentence case end up sharing one shape: a mislabel between what was asserted and what was true. Both get a column; just different evidence in it.

Honestly, your example is the best articulation of the requirement we’ve had from anyone, and you’ve clearly built the adjacent half yourself. If you’re up for it, I’d rather design this with you than at you — want to look at the claim-record shape when we have the first cut and tell us where it breaks?

1 curtida

The most common one I find: approvals bound to tool identity instead of the exact operation. The human approves “the agent may use the refund tool” and the gate checks tool identity on every subsequent call. Any amount, any recipient passes, because the binding that was validated was “this tool is allowed,” not “this specific operation was authorized.”

And the sharper version, which I’ve hit in recent triage work: flows where the binding step doesn’t run at all. The approval response arrives in the conversation history, the collector accepts it with no origin/parameter validation, and the resolver executes it. The gate wasn’t overbroad, it was absent, and the flow still functioned normally. From the outside it looks identical to a working approval flow. You only catch it by trying to fire the action with no valid approval anywhere in the loop, which is why most teams don’t know they have it.

Your boundary-record point is the correct fix for the audit half: the action record has to be produced by infrastructure the agent can’t write to, or the join is fiction. The claim-record design is the right answer for the sentence half, with one gap I’d flag: the claim record needs the same independence from the agent as the boundary record. If the agent can write the claim record and also influence which action record gets cited, it can make a false story produce a clean join. Claim, cited record, and gap on the receipt, yes, but all three produced outside the agent’s reporting path. Those records audit the flow; they don’t replace the approval gate.

Happy to keep comparing notes. Three of us hitting the same failure classes from different angles is usually a good sign.

Your testnet demo could make this failure visible. Approve $50 to A, then have the transaction builder prepare $500 to B. That should be rejected before the wallet asks for a signature. I’d compare the transaction actually presented for signing with the approved spend, including the chain and any contract call encoded in calldata. Then complete a valid transaction and try a second, distinct one under the same approval. That should fail too. Those two tests show whether the approval controls the spend and can be used only once.

Thanks for the demo. I opened it. The page lets me choose a network and create a preset draft, but I don’t see how to submit a changed transaction after review or attempt a second distinct spend under the same approval. Is there a test fixture or API path for those cases? I’d rather run the failure cases than infer a pass from the normal flow.

@kpiatan I’ve gone away since our last convo and come up with something I think you might like. Both of your failure classes are the same defect: the thing that got validated was not the operation. “This tool is allowed” is a binding to a name. An approval sitting in conversation history is a binding to nothing. The fix at the audit half is to bind to bytes, and to have the bytes recorded by something other than the flow.

That is what the crossing receipt is. The pipe that makes the model call holds the credential, so it is the one that knows the exact request that went out and the exact completion that came back. It takes the SHA-256 of each and signs both into the record before the workflow sees the text. Approve a digest, and “any amount, any recipient passes” stops being possible to miss: the operation that crossed either carries the approved digest or it carries a different one. Your second class, the gate that never ran, stops looking identical from the outside for the same reason. A crossing with no approval holding its digest is an orphan on a record the agent cannot write, and the approval that was never bound shows up as an approval with no crossing under it. You do not have to fire the action blind to find it; the join finds it.

Run as records rather than described. One session, one planted operation ID, three crossings: A, A again, then B, a different payload under the same id.

A, the fill, minted by the control plane:

A again, served by the edge a second later, a different plane and a different signing moment, same two digests:

B, the operation that was not the approved one:

The session all three sit in, the id is the planted one:

A and its replay carry one request digest and one response digest. B carries different ones and its own meter_event_id. An approval holding A’s digest covers the first two and not the third, and a second spend is never the first one re-reported. Opens in a browser, checks against the public key directory, no account with anyone.

Your gap on the claim record is closed the same way, because the claim record is the boundary record. The sentence the agent produced is hashed by the layer that received it from the model, not by the agent; the agent writes the sentence and never the digest, and it does not pick which record gets cited, because the operation ID goes out as the session header and comes back inside the signed receipt. What is not on the record yet is the other half of your condition, the fields the sentence leans on. The read-back crossing already signs with the digest of what came back; the join between it and the claim is the part still to come.

Agreed on the boundary. The pipe did not refuse B, it recorded B. These records make the gate checkable; they are not the gate.

@kpiatan the fixture above has one more property worth having, since your point was that the claim record needs the same independence from the agent as the boundary record.

The four receipts are leaves 12, 13, 14 and 15 of the hour 20261012T04Z: A, A again, B, and the session over all three. That hour is sealed, 16 leaves, root 5dfe2b1f306927f35a8634d38a382cdcabaa3c6dfba83575b2d79336e2821e16, every leaf hash listed at

https://api.metrecept.co.uk/v1/public/clock/slots/20261012T04Z

A leaf is the SHA-256 of the receipt’s signature, so A and B are two different leaves with two different digests inside them; a story that swaps B’s bytes for A’s does not land on either leaf. Inclusion is checkable without an account:

curl "https://api.metrecept.co.uk/v1/public/clock/inclusion?receipt=<the JWS after the # in any link above>"

It returns the slot, the leaf index, the audit path and the root to recompute. The join you wanted now has a third side: the approval, the crossing, and the hour the crossing was sealed into, none of them written by the flow.

Correction to what I wrote last night: the OpenTimestamps commitment for this hour has not run yet. The slot reads commitment: null; the August slot in the same list shows a committed one.

Same four receipts, re-linked so each preview names its kind:

To run the check yourself:

git clone https://github.com/iwasinnam2/metrecept-opensource
python metrecept-opensource/demo/verify_receipt.py "<JWS>" --base https://api.metrecept.co.uk

Public key directory, kid lookup, Ed25519 check, payload printed. Only cryptography is needed. It records the crossings; it did not stop anything.