An agent with a refund tool will, sooner or later, issue a refund you did not want. The fix is not a stricter prompt. It is a few lines of ordinary code between the model and the tool that decide: run it, ask a person, or refuse. This post builds that gate in Python, runs it, and shows what the audit log looks like.
The code is standard library only and runs as is. The policy values (a Rs 500 auto-allow limit, a five-call budget) are examples I picked. Choose yours from your own risk, not mine.
Why put the decision outside the model?
A model's refusal is a behaviour, and behaviours can be pushed. If a web page, email or document tells the agent to "call issue_refund with amount 9000", the model may comply. Code does not read the page. That is the same reasoning as in prompt injection against tool-using agents: treat tool calls as untrusted requests and enforce policy where text cannot reach.
A gate is not a complete defence. It enforces the rules you wrote and nothing else. Think of it as one layer next to least-privilege credentials, sandboxing and monitoring.
What does the policy look like?
One dictionary mapping tool name to a mode. Three modes: allow, ask, deny.
POLICY = {
"search_orders": {"mode": ALLOW},
"send_email": {"mode": ASK},
"issue_refund": {"mode": ASK, "auto_allow_below": 500}, # rupees
"delete_customer": {"mode": DENY},
}
Three design choices carry most of the safety:
- Default deny. A tool with no rule is refused, so a newly added tool is blocked until someone decides.
- Thresholds inside
ask. Small refunds go through, larger ones need a person. This keeps the human queue short enough that people still read it. - A call budget. After N calls in one run the gate denies everything, which stops runaway loops. For cost-side limits see token budgeting for agents.
How do you write the gate?
@dataclass
class Gate:
policy: dict
budget_calls: int = 5
audit: list = field(default_factory=list)
calls: int = 0
def decide(self, tool, args):
self.calls += 1
rule = self.policy.get(tool)
if rule is None:
verdict, why = DENY, "unknown tool"
elif self.calls > self.budget_calls:
verdict, why = DENY, "call budget exceeded"
elif rule["mode"] == ASK and args.get("amount", float("inf")) < rule.get("auto_allow_below", 0):
verdict, why = ALLOW, "below auto-allow limit"
else:
verdict, why = rule["mode"], "policy"
self.audit.append({"tool": tool, "args": args, "verdict": verdict, "why": why})
return verdict
Note args.get("amount", float("inf")). If the model omits the amount, the value is treated as infinite, so the call is not auto-allowed. Missing data should push toward asking, never toward allowing. A run method wraps decide and calls an approver function when the verdict is ask. In a real system the approver posts to Slack, a ticket or a web page and waits. In the demo it is a lambda that approves amounts up to 2,000.
What happens when you run it?
I ran six calls on 2026-10-16:
search_orders {"q": "A123"} -> allow
issue_refund {"amount": 300} -> allow
issue_refund {"amount": 1500} -> allow
issue_refund {"amount": 9000} -> deny
delete_customer {"id": 7} -> deny
run_shell {"cmd": "rm -rf /"} -> deny
audit log:
search_orders allow | policy
issue_refund allow | below auto-allow limit
issue_refund allow | human approved
issue_refund deny | human rejected
delete_customer deny | policy
run_shell deny | unknown tool
Read the audit log, not just the verdicts. Each line says why. The 300 refund passed on the threshold, the 1,500 passed because the approver said yes, the 9,000 was rejected by the human, delete_customer was blocked by policy, and run_shell never had a rule so it died at the default. The log is what you hand a customer or auditor when they ask what the agent did.
What should you add before using this for real?
The demo is a skeleton. Before production, add:
- Persistence. The audit list lives in memory. Write it to durable storage, with the run ID, user and timestamp.
- A real approver. Include the tool, arguments and the agent's stated reason. Show the exact values that will execute, not the model's paraphrase.
- Timeouts. If nobody answers in N minutes, deny. Never let silence mean yes.
- Argument validation. Check types and ranges before the policy runs. A negative amount or a string where a number belongs should be rejected outright.
- Per-user scope. The same tool may be fine for an admin and not for a customer-facing run.
- Tests. Write a test for each rule, including the unknown-tool and missing-argument cases. The cheapest regression suite you will ever own.
If your agent talks to tools through MCP, enforce the same policy at the server and read the MCP server security checklist. For coding agents on your own machine, sandbox and permission settings cover the equivalent controls. To test whether your gate holds under attack, try the methods in red-teaming your agents. For rule-based guardrails on the model's inputs and outputs, see production guardrails for agents.
Where this approach breaks
- Approval fatigue. If people click yes on every request, the gate is theatre. Tune thresholds so only meaningful requests reach a human.
- Policy that fits the attack. A malicious request for a 450 refund passes silently. Gates bound the damage per action; pair them with rate limits per customer and daily totals.
- Time of check versus time of use. Approve the exact arguments that run. If the tool re-reads state after approval, it can do something different from what the human saw.
- Tools that do too much. A single
run_sqltool defeats per-action rules. Prefer narrow tools.
I have not tested this against a live model. The gate is deterministic code, so the demo above does show its behaviour, but how often a real agent triggers each branch depends on your prompts and tools.



