AI Engineering
tutorial
Featured

Content Filters Watch What Your Agent Says. Nothing Watches What It Does.

Your output guardrails inspect the words an agent produces. They say nothing about the refund it just issued, the deploy it just triggered, or the email it just sent. Agent guardrails became their own discipline this year for a reason. Here is the action-layer gate that decides allow, deny, or escalate on every tool call, evaluated as policy-as-code before the call runs, not logged after it already happened.

Viral Ruparel
10 min read
Share:

Your agent has output guardrails. Good. They scan what it says on the way out and stop it from leaking one customer's data into another customer's reply or promising a refund policy that does not exist. That work matters, and if you have not done it, do it. But it covers the wrong surface for the risk that is actually growing.

Because your agent no longer just talks. It issues the refund. It triggers the deploy. It sends the email, updates the CRM record, cancels the subscription, opens the ticket, moves the money. Those are not words, they are actions with side effects in systems you cannot un-ring. And the guardrail that reads the model's text has nothing to say about any of them. It inspects the sentence "I have processed your refund" and finds it perfectly safe, while the refund it describes has already left your account.

This is why agent guardrails split off into their own discipline this year, separate from LLM content guardrails. A content filter asks "is this output acceptable to show." An action gate asks a different question: "is this specific thing the agent is about to do allowed to happen, right now, for this user." The two are not the same layer and one does not cover the other. This post is about the second one: an authorization gate that sits at the tool boundary and returns a decision on every action before the action runs.

The decision has to happen before the side effect, and logging is not deciding

Most teams get to production with observability and call it governance. Every tool call is traced, every action is logged, there is a dashboard. That tells you what the agent did. It tells you at 2am, in a query, after the refund cleared and the customer already spent it. Logging is a record of the past. Authorization is a decision about the next step, and it only counts if it happens before the step, not after.

The place to make that decision is the one place every tool call already has to go through: the tool gateway, the harness code that takes the model's requested tool call and actually invokes the function. Not inside the tools themselves. The moment permission checks live inside individual tool implementations, the first tool a teammate adds without remembering the check is your hole, and nobody notices until it is exercised. One chokepoint, every action, one decision.

Start with the shape of that decision and the context it needs.

// Every tool call resolves to exactly one of these before it runs.
export type Decision =
  | { effect: "allow" }
  | { effect: "deny"; reason: string }
  | { effect: "escalate"; reason: string; approvers: string[] };

// Everything the policy needs to judge one action. Critically, this is built
// by the harness from trusted server-side state, NOT from anything the model
// said. The agent supplies the tool name and arguments; it does not get to
// declare who it is acting for. Otherwise it can just claim to be an admin.
export interface ActionContext {
  tool: string;                       // "issue_refund", "deploy", "send_email"
  args: Record<string, unknown>;      // the arguments the model chose
  principal: {                        // resolved from the session, not the prompt
    userId: string;
    tenantId: string;
    roles: string[];
  };
  agentId: string;                    // which agent is acting
  runId: string;                      // ties the action to a session for audit
}

That last comment is the part people skip and regret. If the model can put role: "admin" into the context, prompt injection turns into privilege escalation in one hop. The principal, the tenant, the roles all come from the authenticated session the agent is running inside. The model gets to propose a tool and its arguments. It does not get to describe its own permissions. This is the same trust boundary that makes the confused deputy problem bite, and the same discipline fixes both: never let the untrusted side of the boundary supply the facts the security decision depends on.

Policy as code, evaluated in order, default deny at the bottom

With the context defined, the policy is a function from context to decision. Keep it as code your team reviews in pull requests, versioned with everything else, testable in isolation. Rules run in order, the first one that has an opinion wins, and if none of them permits the action, it is denied.

type Rule = (ctx: ActionContext) => Decision | null; // null = this rule abstains

const rules: Rule[] = [
  // Hard denies come first so nothing below can accidentally allow them.
  (ctx) =>
    ctx.tool === "delete_account"
      ? { effect: "deny", reason: "no agent is permitted to delete accounts" }
      : null,

  // Money is allowed up to a limit; past it, a human decides. Scale the
  // gate to the blast radius instead of gating every refund the same way.
  (ctx) => {
    if (ctx.tool !== "issue_refund") return null;
    const cents = Number(ctx.args.amountCents ?? 0);
    if (!Number.isFinite(cents) || cents < 0) {
      return { effect: "deny", reason: "refund amount is not a valid number" };
    }
    if (cents > 50_000) {
      return {
        effect: "escalate",
        reason: `refund of $${(cents / 100).toFixed(2)} exceeds the auto-approve limit`,
        approvers: ["finance-oncall"],
      };
    }
    return { effect: "allow" };
  },

  // Writes are allowed only inside the acting user's own tenant. The target
  // tenant is read from the args, then checked against the trusted principal.
  (ctx) => {
    if (!isWrite(ctx.tool)) return null;
    const target = String(ctx.args.tenantId ?? ctx.principal.tenantId);
    return target === ctx.principal.tenantId
      ? { effect: "allow" }
      : { effect: "deny", reason: "cross-tenant write is not permitted" };
  },
];

export function decide(ctx: ActionContext): Decision {
  for (const rule of rules) {
    const d = rule(ctx);
    if (d) return d; // first rule with an opinion wins
  }
  // Nothing explicitly permitted this action. Refuse it and say so.
  return { effect: "deny", reason: "no policy permits this action" };
}

The default at the bottom is the whole design in one line. Default deny means a new tool someone ships next month, or a new argument shape nobody wrote a rule for, is refused until a human decides it is safe and adds a rule. Default allow means the opposite: every gap is a yes until you notice it. You will not notice it. Make the safe direction the one that happens when you forget.

Notice too that the gate does real input validation on the arguments, not just identity checks. An amountCents of NaN or a negative number is a denied action, not a permitted one that blows up somewhere downstream. The model chose those arguments, so they are untrusted input, and the gate is the natural place to reject the malformed ones.

Wiring it into the tool runner without breaking the agent

Now put the decision in front of execution. Three things make this survivable in production: it fails closed, it hands the model a readable error instead of throwing, and it ships in shadow mode before it enforces anything.

export async function runTool(
  ctx: ActionContext,
  execute: () => Promise<unknown>,
) {
  let decision: Decision;
  try {
    decision = decide(ctx);
  } catch (err) {
    // A crash in the policy code must never mean "let it through". A broken
    // gate that fails open is worse than no gate, because you think you have one.
    decision = { effect: "deny", reason: "policy evaluation failed" };
  }

  audit(ctx, decision); // log every decision, allows included, before acting

  if (SHADOW_MODE) {
    // Roll out here first. Record what the gate WOULD have done and let the
    // action run anyway. Watch the deny and escalate rates against real
    // traffic for a week, fix the rules that are wrong, then turn this off.
    return execute();
  }

  switch (decision.effect) {
    case "allow":
      return execute();

    case "escalate": {
      // Hand off to the approval flow: pause the run, wait for a human,
      // resume only if approved. This is the escalate branch, not the whole gate.
      const approved = await requestApproval(ctx, decision);
      return approved
        ? execute()
        : toolError("denied_by_approver", decision.reason);
    }

    case "deny":
      // Do not throw. Return a structured, recoverable error the model can
      // read. It can choose a different path; it cannot retry its way through.
      return toolError("action_not_authorized", decision.reason);
  }
}

The escalate path is where this connects to work you may already have. A gate that returns escalate is choosing to route this one action to a person, which is exactly the human approval flow that pauses the run and resumes it on a decision. The policy engine is the layer that decides which small slice of actions even reaches a human. Everything else it can allow or deny on its own, so you get the safety of a human in the loop without a human in every loop.

Returning the deny as a tool result rather than an exception matters more than it looks. If you throw, the run crashes and you have turned a policy decision into an incident. If you return action_not_authorized as the tool's output, the model sees that the action was refused and why, and it can do something else: ask the user, try a permitted alternative, or give up cleanly. What it cannot do is call the same denied action again and get a different answer, because the gate is deterministic. If you find the agent hammering a denied tool in a loop, that is a separate failure worth its own no-progress guard.

Tradeoffs and the things that bite

The gate adds latency to every tool call, but the honest number is small. Policy evaluation is a few function calls against data you already have in memory, so you are spending microseconds, not a model round trip. The exception is escalate, which can take minutes or hours while a human decides, and that is the point of it, not a cost to optimize away. Keep the auto-approve limits generous enough that the common case never escalates.

Policy sprawl is the real long-term risk. A dozen rules is readable. Two hundred rules written by four teams over a year is its own legacy system, and nobody can tell you what a given action will resolve to without running it. Two habits keep it honest. Write tests that assert decisions for the actions you care about, the same way you test any other branch, so a rule change that quietly starts allowing cross-tenant writes fails CI instead of shipping. And when the rules genuinely outgrow code, move them into a dedicated policy engine like Open Policy Agent, which is the standard reference for this pattern, so policy lives as reviewable data with its own tooling instead of an ever-growing switch statement.

Watch the context-building code as closely as the rules. The strongest policy in the world is worthless if the principal it reads was populated from something the model influenced. Every field the decision depends on has to trace back to the authenticated session, not to the conversation. This is the one bug in this whole design that turns your safety layer into your attack surface, so it is the one to review hardest.

And resist the urge to gate everything at the same intensity. Reading a record and moving a million dollars are not the same risk, and a gate that treats them identically trains your team to rubber-stamp escalations until the approval means nothing. Match the friction to the blast radius: let the safe majority run untouched, deny the things no agent should ever do, and spend your humans' attention only on the genuinely irreversible middle.

The takeaway

Content guardrails watch what your agent says. As agents start spending money and changing state, the risk moved to what they do, and that surface needs its own layer: a decision on every action, made at the one chokepoint every tool call passes through, evaluated as policy-as-code before the side effect, defaulting to deny, failing closed, and escalating only the slice that truly needs a person. It is a couple hundred lines and a habit of routing every tool call through one door. The payoff is that an unauthorized action stops being something you find in the logs the morning after and becomes something that was never allowed to happen.

If your agents have started taking real actions in production and the only thing standing between a bad tool call and your systems is a content filter and some logging, that gap is exactly where a wrong refund or a cross-tenant write turns into a real incident. Book a consultation call and we can map which of your agent's actions actually need a gate and put a policy layer on the ones that matter.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

How is action authorization different from an output guardrail?+

An output guardrail inspects the text the model produced: it catches leaked PII, unsafe language, or a promise you never authorized in the words on the way out. Action authorization sits one layer deeper. It decides whether a tool call the agent wants to make (issue a refund, deploy, delete a record, send a message) is allowed to run at all, given who the agent is acting for, what the arguments are, and the current risk context. Content filters watch what the agent says. Action authorization watches what it does, and it runs before the side effect happens rather than after.

Where should the authorization check live?+

In the one place every tool call has to pass through: the tool gateway or the agent harness that actually invokes tools. Do not scatter permission checks inside individual tool implementations, because the first tool someone adds without the check becomes the hole. A single chokepoint that builds the action context from trusted server-side state, evaluates policy, and only then dispatches to the tool means an unauthorized action is structurally impossible rather than something you hope each tool remembered to guard.

Should the policy default to allow or deny?+

Default deny. If no rule explicitly permits an action, the gate should refuse it and log why. Default allow means every new tool and every new argument shape is permitted until someone remembers to write a rule against it, which is the same failure mode as an allowlist you forgot to update. The one nuance is rollout: run the gate in shadow mode first so a default deny does not break a live agent on day one, read the would-have-denied rate against real traffic, tune the rules, then flip enforcement on.

Related Articles

AI Engineering

A New Model Dropped This Week. Don't Just Bump the String.

Four frontier models shipped in the last 72 hours and your instinct is to change one env var and redeploy. That one-line model swap is the riskiest change you will make this quarter, because tool-calling, output format, cost, and the prompt you tuned all shift at once and nothing throws. Here is how to treat a model upgrade like the dependency change it actually is: pin it behind an adapter, gate it on an eval run against real traffic, and shadow it before you flip.

AI Engineering

Your Agent Started Calling Other Agents. Who Verified Theirs?

A2A hit v1.0 this year and teams are wiring their agents to other teams' and vendors' agents. The moment your agent fetches an Agent Card and acts on it, you have extended your trust boundary to identity and code you do not control. The spec advertises auth schemes but leaves verification to you. Here is the admission gate that makes an outbound A2A call safe: verify the signed card, scope the credential you hand over, and block replays.

AI Engineering

Your Model Started Showing Its Work. Now You Have to Handle It.

Reasoning traces are the feature everyone turned on this month and nobody planned for. Handled wrong, they quietly triple your token bill, dump intermediate reasoning full of customer data into your logs, and break your tool loop in a way that looks like the model got dumber. Here is how to treat extended thinking as something you manage at the boundary, not something you print.