DEV Community

Cover image for How to Bound AI Agents With Policy Gates
Ranknod
Ranknod

Posted on

How to Bound AI Agents With Policy Gates

An AI agent becomes much more interesting the moment it can do something.
It can send the email. Update the record. Publish the page. Delete the file. Trigger the deployment.
That is also the moment when a prompt stops being a sufficient safety boundary.
You can tell an agent:

Never make a destructive production change without approval.

But the component interpreting that instruction is also the component deciding what to do next.
If the action matters, "the model was told not to" is a weak enforcement mechanism.
A stronger design puts a policy gate between the agent and the side effect.

The agent should propose. Another layer should decide.

The simplest mental model is:

User / Workflow
      ↓
     Agent
      ↓
Proposed Action
      ↓
  Policy Gate
   ↙   ↓   ↘
Allow Review Deny
   ↓          ↓
Executor    Stop
Enter fullscreen mode Exit fullscreen mode

The important separation is between:
reasoning authority and execution authority.
The model may decide that calling delete_document appears useful.
It should not automatically follow that the model has authority to perform that deletion.
OWASP describes excessive agency as a risk created by excessive functionality, permissions, or autonomy. Its mitigation guidance includes minimizing tool permissions, requiring human approval for high-impact actions, and enforcing authorization in downstream systems instead of depending on the LLM to decide whether an action is allowed.
That gives us a useful architecture principle:

Treat the agent as a proposer, not as the final authority.

What a policy gate actually evaluates

A useful policy gate should receive more than the name of the tool.
Consider these two calls:

update_file("/tmp/draft.md")
update_file("/app/production/config.json")
Enter fullscreen mode Exit fullscreen mode

The operation may be the same.
The consequence is not.
A policy decision may need context such as:

type AgentAction = {
  actorId: string;
  userId: string;
  tenantId: string;
  tool: string;
  operation: string;
  resource: string;
  environment: "dev" | "staging" | "production";
  arguments: Record\<string, unknown>;
};
Enter fullscreen mode Exit fullscreen mode

The gate can then return something explicit:

type PolicyDecision =
  | { effect: "allow"; reason: string }
  | { effect: "review"; reason: string }
  | { effect: "deny"; reason: string };
Enter fullscreen mode Exit fullscreen mode

This is more useful than a single isSafe boolean because not every action belongs in one of two buckets.
Some actions should run automatically.
Some should never run.
Some are acceptable only after human review.

Start with three decision classes

A first implementation can be boring.
That is a feature.

function evaluate(action: AgentAction): PolicyDecision {
  if (
    action.environment === "production" &&
    action.operation === "delete"
  ) {
    return {
      effect: "deny",
      reason: "Production deletion is not available to this agent."
    };
  }

  if (
    action.environment === "production" &&
    action.operation === "write"
  ) {
    return {
      effect: "review",
      reason: "Production writes require human approval."
    };
  }

  if (action.operation === "read") {
    return {
      effect: "allow",
      reason: "Read operation is within the permitted scope."
    };
  }

  return {
    effect: "deny",
    reason: "No explicit policy permits this action."
  };
}
Enter fullscreen mode Exit fullscreen mode

Then force every effect-producing tool call through the gate:

async function dispatch(action: AgentAction) {
  const decision = evaluate(action);

  if (decision.effect === "deny") {
    throw new Error(\`Blocked by policy: ${decision.reason}\`);
  }

  if (decision.effect === "review") {
    return queueForHumanApproval(action, decision);
  }

  return executeRegisteredTool(action);
}
Enter fullscreen mode Exit fullscreen mode

This is illustrative code, not a production authorization system.
The important part is the shape of the architecture.
There should not be an alternative execution path where the agent can bypass dispatch() and call the underlying tool directly.

Deny by default

A common mistake is to define the dangerous things you know about and allow everything else.
That works until somebody adds a new tool.
Suppose today's agent can:

  • read CMS entries;
  • edit drafts;
  • publish pages.

You create a rule requiring approval for publish.
Three weeks later, another developer adds:

bulk_publish
Enter fullscreen mode Exit fullscreen mode

If unknown operations are allowed by default, your policy boundary has silently expanded.
A safer rule is:

No matching permission means no execution.

This makes new capabilities opt-in rather than accidentally inherited.

Put policy outside the prompt

Prompts are useful for guiding behavior.
They are not equivalent to authorization.
The difference becomes clearer when you imagine prompt injection, stale context, hallucination, or simply a model interpreting a vague instruction differently than you expected.
Policy should operate on the proposed action after the model has decided what it wants to do but before the side effect occurs.
Systems such as Open Policy Agent formalize a similar separation between policy decision-making and policy enforcement. OPA receives structured input, evaluates policy separately from application logic, and returns a decision to an enforcement point.
You do not need OPA to use this pattern.
The architectural separation is the useful part.

Bind the decision to real context

A policy gate becomes more useful as it understands the context that changes risk.
For example:

operation = send_email
Enter fullscreen mode Exit fullscreen mode

is not enough.
You may need:

recipient = internal
recipient = customer
recipient = 8,000-person mailing list
Enter fullscreen mode Exit fullscreen mode

Likewise:

operation = deploy
environment = staging
Enter fullscreen mode Exit fullscreen mode

is different from:

operation = deploy
environment = production
Enter fullscreen mode Exit fullscreen mode

Useful policy inputs can include:

  • authenticated user;
  • tenant;
  • environment;
  • resource;
  • tool;
  • operation;
  • argument values;
  • current workflow stage;
  • data classification;
  • approval status;
  • time or expiry;
  • rate limits.

Do not make the LLM invent these values when authoritative system data exists.
Resolve them from trusted application state.

Approval should interrupt execution, not decorate it

A bad human-in-the-loop implementation looks like this:

  1. the agent decides;
  2. the operation begins;
  3. the UI asks somebody whether the action was okay.

That is auditing, not approval.
Real approval changes the execution state:

PROPOSED
   ↓
POLICY CHECK
   ↓
REQUIRES APPROVAL
   ↓
PAUSED
   ↓
HUMAN DECISION
 ↙           ↘
APPROVED     REJECTED
   ↓             ↓
EXECUTE         STOP
Enter fullscreen mode Exit fullscreen mode

The action should remain unexecuted until the required approval exists.
For sensitive workflows, the reviewed payload should also be the payload that executes. If an agent can change arguments after approval, the human approved an explanation rather than the real operation.

Minimize the agent's credentials too

Policy gates are not a replacement for least privilege.
If an agent should only read a database, do not give its tool identity write privileges and depend on the policy layer to behave forever.
If it should only create drafts, do not give it unrestricted publication credentials.
The layers should reinforce one another:

Agent behavior
    ↓
Tool allow-list
    ↓
Policy gate
    ↓
Human approval where needed
    ↓
Least-privilege credential
    ↓
Downstream authorization
Enter fullscreen mode Exit fullscreen mode

A failure in one layer should not immediately become unrestricted authority.

Log policy decisions, not just agent messages

When an incident happens, the interesting question is not only:

What did the model say?

You also need to know:

  • what action was proposed;
  • which policy version evaluated it;
  • what decision was returned;
  • why;
  • whether approval was required;
  • who approved it;
  • what ultimately executed.

OPA, for example, supports decision logs containing policy-query inputs and decisions specifically for auditing and debugging.
The same principle applies even if your policy system is custom.
Generated reasoning is not an audit trail.

A practical implementation sequence

Do not start by trying to govern every possible agent behavior.
Start with side effects.
Inventory the tools that can change something outside the model's context window.
Then classify each operation:
Auto-allow: low-consequence, reversible operations.
Review: consequential operations that are legitimate but require judgment.
Deny: operations the agent should never perform in this role.
Then make sure there is exactly one governed path into the executor.
Only after that should the policy model become more sophisticated.

The useful boundary is outside the model

Better models will reduce some errors.
Better prompts will improve some behavior.
Neither changes the fundamental authority problem.
If an agent can affect another system, you need to decide where the model's discretion stops.
A policy gate makes that boundary executable.
The agent can still reason, plan, adapt, and propose.
It simply cannot convert every conclusion into a side effect on its own.

Implementation checklist

  • Inventory every agent tool that creates an external side effect.
  • Remove unnecessary tools and permissions.
  • Normalize proposed actions into structured inputs.
  • Evaluate policy outside the LLM.
  • Default unknown actions to deny.
  • Require human approval for selected high-impact operations.
  • Make every side-effecting call pass through the enforcement path.
  • Preserve the exact approved parameters where approval is required.
  • Use least-privilege downstream credentials.
  • Log policy decisions separately from model output.
  • Test bypass paths, not just expected behavior.

The architectural question I would ask about any agent system is simple:
If the model decides to do something it should not do, what component has the authority to stop it?

Top comments (0)