Laravel Policies for AI Tool Calls: Authorize Users Before Agents Act

Laravel Policies for AI Tool Calls: Authorize Users Before Agents Act

Your support agent receives a message: “Please refund order 89412.”

The model decides to call a tool named refund_order. Your backend accepts the request because the API token is valid, the JSON payload is well-formed, and the tool handler exists.

The refund goes through.

Then you discover the authenticated user was a support contractor who can only view orders, not refund them. Or worse: the order belonged to another team entirely.

This is the authorization gap in AI systems. The model may be smart enough to choose the right tool, but it is not allowed to decide who may use it. In a Laravel application, that decision belongs to your authorization layer: gates, policies, and explicit domain rules.

AI agents do not remove the need for Laravel authorization. They make it more important, because agents can generate actions faster than a human can click through a UI.

TL;DR

  • Do not treat a tool call as a harmless model output. Treat it as an application action.
  • Map every agent tool to a Laravel ability and, where possible, a concrete model subject.
  • Filter the tool catalog before the model sees it, but authorize again before execution.
  • Use policies for business rules: ownership, state, limits, team membership, and workflow constraints.
  • Return denials as structured tool results, not raw exceptions.
  • Separate the agent’s technical identity from the end user’s authority.
  • Add audits, idempotency keys, rate limits, and tests around the authorization boundary.

📋 Table of Contents

  1. The Real Problem: Agents Turn Intent Into Actions
  2. Pattern 1: Treat a Tool Call Like a Controller Action
  3. Pattern 2: Filter the Agent’s Tool Catalog Before the Model Sees It
  4. Pattern 3: Resolve the Real Subject Before Asking “Can?”
  5. Pattern 4: Use Policies for Business Constraints, Not Just Roles
  6. Pattern 5: Use Gate::inspect When the Agent Needs a Programmable Result
  7. Pattern 6: Keep Agent Credentials Separate From User Authority
  8. Pattern 7: Prevent Hidden Privilege Escalation Through Policy Shortcuts
  9. Pattern 8: Test Policies Like Agent Endpoints
  10. Pattern 9: Add Guardrails That Policies Cannot Provide
  11. Choosing the Right Authorization Strategy

The Real Problem: Agents Turn Intent Into Actions

A normal Laravel request has a fairly predictable shape. A user visits /orders/89412/refund, submits a form, or calls an API endpoint. The route, controller, middleware, and policy form a chain of responsibility. You know where authorization should happen.

An agent loop changes that chain.

The model receives a user goal, reasons about it, selects a tool, produces arguments, and asks your application to execute the result. The action may not correspond to a predefined route. It may be represented as a JSON object:

{
  "tool": "refund_order",
  "arguments": {
    "order_id": 89412,
    "reason": "customer request"
  }
}

If your authorization only checks whether the agent process itself is authenticated, you have created a very flexible internal API with almost no permission boundaries.

The important question is not:

Can this agent call tools?

The important question is:

Can the human user on whose behalf the agent is acting perform this specific action on this specific subject?

Laravel already has a good answer for that question: policies.

Pattern 1: Treat a Tool Call Like a Controller Action

Scenario:
Your agent framework receives a tool call and immediately executes a PHP function. The function updates a database record because the model asked it to.

Why it matters:
If each tool contains its own ad-hoc permission check, you will eventually miss one. Some tools will check roles. Some will check team membership. Some will check nothing because they started as “read-only” and later became dangerous.

Solution:
Put a single authorization boundary around tool execution. A tool call should enter your application the same way a controller action does: validated input, resolved subject, authorization decision, then execution.

<?php

namespace App\Agents;

use App\Models\User;
use Illuminate\Support\Facades\Gate;

final class ToolCallExecutor
{
    public function __construct(private ToolRegistry $tools)
    {
    }

    public function execute(User $user, array $toolCall): ToolResult
    {
        $tool = $this->tools->resolve($toolCall['name']);

        $subject = $tool->resolveSubject($toolCall['arguments'] ?? []);

        $decision = Gate::forUser($user)->inspect(
            $tool->ability(),
            $subject
        );

        if ($decision->denied()) {
            return ToolResult::denied(
                $toolCall['id'] ?? null,
                $decision->message()
            );
        }

        return $tool->run($user, $toolCall['arguments'] ?? [], $subject);
    }
}

The exact shape of ToolRegistry, ToolResult, and your individual tool classes can vary. The important part is the sequence:

  1. Resolve the tool.
  2. Resolve the subject.
  3. Ask Laravel whether the user may perform the ability on that subject.
  4. Only then run the tool.

Why this works:
You create one choke point. If someone adds a new tool, it cannot bypass authorization unless it also bypasses the executor. That is much easier to review, test, and monitor.

💡 Practical note: do not let tools call Eloquent mutations directly from the model response. The model’s output is a proposal, not a command.

Pattern 2: Filter the Agent’s Tool Catalog Before the Model Sees It

Scenario:
A basic user is chatting with an assistant. The assistant has access to every internal tool: billing adjustments, admin reports, user impersonation, infrastructure actions. The model sees all of them in its tool schema.

Why it matters:
Exposing forbidden tools creates two problems. First, the model may try to call them. Second, prompt injection becomes more dangerous. If untrusted content can influence the model, you do not want that content anywhere near a tool named delete_workspace.

Solution:
Generate the tool list per user. If a user cannot perform a category of action, do not advertise the corresponding tool to the model.

<?php

namespace App\Agents;

use App\Models\User;
use Illuminate\Support\Facades\Gate;

final class ToolCatalog
{
    public function __construct(private iterable $tools)
    {
    }

    public function forUser(User $user): array
    {
        $visible = [];

        foreach ($this->tools as $tool) {
            if (! Gate::forUser($user)->allows($tool->listingAbility())) {
                continue;
            }

            $visible[] = $tool->schema();
        }

        return $visible;
    }
}

A tool’s listing ability can be coarser than its execution ability. For example:

  • listRefundTool may control whether the tool appears.
  • refund may control whether a specific order can be refunded.

This is not the final authorization check. It is a surface-area reduction.

Why this works:
The model cannot call what it cannot see. This lowers hallucinated tool usage, reduces prompt-injection impact, and makes the assistant feel more consistent because users are not offered actions they cannot perform.

⚠️ Gotcha: filtering the tool list is not enough by itself. A tool may be visible because the user can sometimes use it, but the specific subject may still be forbidden.

Pattern 3: Resolve the Real Subject Before Asking “Can?”

Scenario:
Your tool is update_project. The agent sends project_id: 4001. Your code checks:

$user->can('update', Project::class)

That looks reasonable, but it answers the wrong question. It asks whether the user can update any project in the abstract. It does not ask whether the user can update project 4001.

Why it matters:
Most real authorization rules are subject-specific. A user may edit their own projects, but not another team’s projects. A finance admin may approve refunds, but only inside their own organization. An agent may be able to summarize a document, but not the document belonging to a private space.

Solution:
Each tool should resolve the subject from its arguments before authorization.

<?php

namespace App\Agents\Tools;

use App\Models\Order;

final class RefundOrderTool
{
    public function resolveSubject(array $arguments): Order
    {
        $orderId = $arguments['order_id'] ?? null;

        if (! $orderId) {
            throw ToolArgumentError::missing('order_id');
        }

        return Order::query()->findOrFail($orderId);
    }
}

Then the executor checks:

$user->can('refund', $order)

Now Laravel can dispatch to OrderPolicy@refund, where the actual business rules live.

Why this works:
Policies are strongest when they receive concrete domain objects. A policy can inspect the order’s team, status, amount, refund history, payment provider, and anything else that matters.

🔍 Why this matters: “Can the user use this tool?” and “Can the user use this tool on this record?” are different security questions.

Validate arguments before resolving subjects. If the model sends malformed data, you want a clean validation failure, not an authorization bypass or a 500 error.

Pattern 4: Use Policies for Business Constraints, Not Just Roles

Scenario:
Your team adds a rule: support agents may refund orders under 100,financeapproversmayrefundordersunder100, finance approvers may refund orders under 100,financeapproversmayrefundordersunder5,000, and anything larger requires manual review. A simple role check does not capture this cleanly.

Why it matters:
If you only check roles, you end up with overly broad permissions. If you sprinkle amount checks through controllers and tools, you end up with inconsistent behavior.

Solution:
Put the rule in the policy.

<?php

namespace App\Policies;

use App\Models\Order;
use App\Models\User;
use Illuminate\Auth\Access\Response;

class OrderPolicy
{
    public function refund(User $user, Order $order): Response
    {
        if (! $user->isOnTeam($order->team_id)) {
            return Response::deny('You cannot access that order.');
        }

        if ($order->refunded_at !== null) {
            return Response::deny('The order has already been refunded.');
        }

        $requiresFinanceApproval = $order->total_in_cents > 500000;

        if ($requiresFinanceApproval && ! $user->isFinanceApprover()) {
            return Response::deny('Refunds over $5,000 require a finance approver.');
        }

        return Response::allow();
    }
}

This policy now answers the real question. It does not merely ask, “Is this person a support agent?” It asks whether this user may refund this order in its current state.

Why this works:
The policy becomes a domain boundary. The agent tool can remain focused on execution: validating input, calling the payment provider, recording the refund, and returning a result.

It also keeps the authorization logic usable across multiple entry points:

  • A controller action from the admin UI.
  • A queue job triggered by a workflow.
  • A CLI command.
  • An AI agent tool call.

🧠 The important part: policies should not perform the action. They should decide whether the action is allowed.

Pattern 5: Use Gate::inspect When the Agent Needs a Programmable Result

Scenario:
Your tool executor calls $user->authorize('refund', $order). The user is not allowed. Laravel throws an AuthorizationException. Your agent loop receives an exception, the model gets a confusing error, and the conversation breaks.

Why it matters:
Exceptions are useful for HTTP controllers, but agent loops often need structured feedback. The model may need to say, “You do not have permission to refund this order,” or ask the user to contact a finance approver.

Solution:
Use Gate::inspect or Gate::forUser(...)->inspect(...) to receive an authorization response object.

<?php

use Illuminate\Support\Facades\Gate;

$decision = Gate::forUser($user)->inspect('refund', $order);

if ($decision->allowed()) {
    // Execute the tool.
}

return ToolResult::fromDecision($decision);

A possible tool result could look like this:

{
  "tool_call_id": "call_9f2c",
  "status": "denied",
  "message": "Refunds over $5,000 require a finance approver."
}

That gives the model something useful to work with. It can tell the user what happened without leaking internal details or retrying endlessly.

Why this works:
Authorization becomes part of the agent’s state machine instead of an uncontrolled failure. You can distinguish:

  • Denied because of permission.
  • Denied because the record is in the wrong state.
  • Denied because a business limit was reached.
  • Failed because the tool execution failed after authorization.

🚨 Production warning: keep denial messages safe. Do not reveal records the user cannot see, internal IDs, stack traces, or sensitive policy logic.

Pattern 6: Keep Agent Credentials Separate From User Authority

Scenario:
Your agent backend authenticates with a server token. The token belongs to the agent service. The service calls tools on behalf of different users.

If you authorize the service account directly, every user inherits the service account’s permissions. That is usually far too broad.

Why it matters:
The agent process is not the principal. The user is the principal. The agent is an intermediary.

In Laravel terms, this often means you need two checks:

  1. Can this technical client execute agent tools at all?
  2. Can the actual end user perform the requested action?

If you use Laravel Sanctum, the first check may involve token abilities or scopes:

<?php

if (! $request->user()->tokenCan('agent:execute')) {
    abort(403, 'Agent execution is not allowed for this token.');
}

But that only proves the token may drive agent behavior. It does not prove that the end user may refund an order, delete a document, or invite a teammate.

You still need the user context:

<?php

$decision = Gate::forUser($endUser)->inspect('refund', $order);

Why this works:
You avoid the classic confused deputy problem. The agent is not allowed to become a universal bypass mechanism just because it has a valid machine credential.

In production, be explicit about how the end user is resolved. Possible patterns include:

  • A user-scoped access token passed from the frontend.
  • A signed delegation assertion.
  • An OAuth token exchange flow.
  • A session-bound agent conversation tied to an authenticated user.

The implementation depends on your architecture. The rule does not: never substitute the agent’s identity for the user’s authority unless you consciously want the agent to act as a system user.

Pattern 7: Prevent Hidden Privilege Escalation Through Policy Shortcuts

Scenario:
Your policies work correctly in normal controllers. Then you connect them to an agent. Suddenly, an administrator user can perform actions through the assistant that would normally require extra confirmation, MFA, or a separate admin panel.

Why it matters:
Many Laravel applications have broad authorization shortcuts. A common one is a policy before method:

<?php

public function before(User $user, string $ability): ?bool
{
    if ($user->isPlatformAdministrator()) {
        return true;
    }

    return null;
}

This is convenient. It is also a reminder that agents inherit the consequences of your authorization model.

If platform administrators can do everything, an agent acting on behalf of a platform administrator can do everything. That may be acceptable. But it should be an intentional decision, not an accidental side effect of connecting tools to a model.

Similar issues appear with:

  • Global Gate::before callbacks.
  • Overly broad “super admin” roles.
  • Tools that accept a user ID as an argument and impersonate someone else.
  • Internal tools that bypass validation because they were originally meant for trusted staff.
  • Tool arguments that allow changing the team, organization, or owner of a record.

Solution:
Review your policies with agent access in mind. Ask:

  • Which abilities bypass normal checks?
  • Which tools allow changing the subject’s owner or team?
  • Which arguments could be manipulated by prompt injection?
  • Which actions should require explicit user confirmation outside the chat?

For especially sensitive tools, require more than a policy check. Require one of the following:

  • A confirmed UI action.
  • A second-factor approval.
  • A workflow approval step.
  • A human review queue.

💡 Practical note: if a tool can create, update, or delete authorization-related records, treat it as a security-critical surface.

Pattern 8: Test Policies Like Agent Endpoints

Scenario:
Your policy tests cover the admin panel. They do not cover the weird payloads produced by agents: missing IDs, IDs from other teams, already-refunded orders, archived projects, or arguments encoded as strings instead of integers.

Why it matters:
Agents produce input that is structurally plausible but semantically risky. They may retry with slightly different arguments. They may be influenced by untrusted content. They may call tools in sequences you did not anticipate.

Solution:
Test policies directly, and test the tool executor as a boundary.

Example with Pest:

<?php

use Illuminate\Support\Facades\Gate;

test('support agents cannot refund orders from another team', function () {
    $user = User::factory()->supportAgent()->create();
    $otherTeam = Team::factory()->create();
    $order = Order::factory()->forTeam($otherTeam->id)->create();

    expect(Gate::forUser($user)->allows('refund', $order))->toBeFalse();
});

You can also test the executor’s behavior:

test('tool executor returns a denial for forbidden refunds', function () {
    $user = User::factory()->supportAgent()->create();
    $otherTeam = Team::factory()->create();
    $order = Order::factory()->forTeam($otherTeam->id)->create();

    $result = $this->executor->execute($user, [
        'id' => 'call_123',
        'name' => 'refund_order',
        'arguments' => [
            'order_id' => $order->id,
        ],
    ]);

    expect($result->status)->toBe('denied');
});

Useful test cases include:

  • User can access their own record.
  • User cannot access another team’s record.
  • User cannot act on a record in a terminal state.
  • User cannot exceed a monetary or quota limit.
  • User cannot use a tool that is hidden from their catalog.
  • Agent token cannot act without a valid end user.
  • Malformed arguments do not bypass authorization.

Why this works:
Policies are not just implementation details. They are security contracts. Agent tool calls should exercise those contracts just as aggressively as HTTP tests do.

Pattern 9: Add Guardrails That Policies Cannot Provide

Policies answer permission questions. They do not answer every operational question.

An agent may be allowed to send emails, but should it send 5,000 emails in one minute? An agent may be allowed to create invoices, but should it create the same invoice ten times because the model retried? An agent may be allowed to update a record, but should that update be auditable?

For these concerns, add guardrails around the policy boundary.

Idempotency keys

Require idempotency keys for tools that create or mutate important resources. If the model retries a tool call, you can avoid duplicate refunds, duplicate payments, or duplicate notifications.

Rate limits and budgets

Limit tool calls by:

  • User.
  • Conversation.
  • Tool.
  • Organization.
  • Time window.
  • Monetary total.

A policy may allow refunds, but a budget guardrail can say, “No more than $10,000 in refunds per user per day without manual approval.”

Audit logging

Log authorization decisions for agent actions with enough context to reconstruct the event.

<?php

use Illuminate\Support\Facades\Log;

Log::channel('agent-audit')->info('agent.tool.authorization', [
    'user_id' => $user->id,
    'tool' => $tool->name(),
    'ability' => $tool->ability(),
    'decision' => $decision->allowed() ? 'allowed' : 'denied',
    'reason' => $decision->message(),
]);

Depending on your compliance needs, you may also log:

  • The conversation ID.
  • The tool call ID.
  • The subject type and ID.
  • The hashed arguments.
  • The agent client identity.
  • The request IP or service origin.

Be careful with sensitive data. You may want to normalize or hash arguments instead of storing them verbatim.

Human confirmation for destructive actions

Some actions should not be completed purely from a model decision. Examples include:

  • Deleting a workspace.
  • Changing billing details.
  • Inviting administrators.
  • Exporting sensitive reports.
  • Deploying production infrastructure.
  • Approving high-value payments.

For these, the agent can prepare the action, but the final authorization should happen through a deliberate user interface.

Choosing the Right Authorization Strategy

Not every agent tool needs the same level of ceremony. A read-only summarization tool is different from a refund tool, and a refund tool is different from a tool that deletes production data.

ApproachBest forStrengthWeakness
Token scopes onlyTechnical client accessSimple to enforce at the API boundaryDoes not capture user-specific or record-specific rules
Role checks inside toolsQuick prototypesEasy to writeScatters authorization logic and becomes inconsistent
Route middleware can:Fixed HTTP endpointsFamiliar Laravel patternNot natural for dynamic tool calls with arbitrary tool names
Gates with ability namesSimple global permissionsLightweight and flexibleWeak when the action depends on a specific record
Model policiesResource-specific actionsExcellent for ownership, state, and domain rulesRequires resolving the correct subject
Policies plus execution guardrailsProduction agents with real side effectsCombines permission, safety, and auditabilityMore implementation work

If I were building this in a real Laravel application, I would use the following as the default architecture:

  1. Authenticate the technical agent client.
  2. Resolve the end user on whose behalf the agent is acting.
  3. Validate the tool name and arguments.
  4. Resolve the subject model or domain object.
  5. Authorize the ability with a Laravel policy.
  6. Apply tool-specific guardrails: idempotency, limits, and confirmation requirements.
  7. Execute the tool.
  8. Return a structured result.
  9. Log the decision and execution outcome.

That sequence may feel heavier than “call the function the model asked for,” but it is the difference between an assistant and an uncontrolled privileged actor.

A Production Checklist for Laravel Agent Tool Authorization

Before letting an agent execute tools against real data, check these items:

  • Every tool has a unique name and a mapped Laravel ability.
  • Every mutating tool resolves a concrete subject where possible.
  • Authorization happens server-side, not in the prompt.
  • The model’s tool catalog is filtered by the end user’s permissions.
  • The executor authorizes again at execution time.
  • Denied tool calls return structured, safe messages.
  • Agent credentials are separate from user permissions.
  • Admin bypasses have been reviewed for agent consequences.
  • Sensitive actions require confirmation outside the model loop.
  • Idempotency keys prevent duplicate mutations.
  • Rate limits and budgets constrain tool usage.
  • Authorization decisions are logged with enough context for audits.
  • Policy tests cover agent-specific payloads and forbidden subjects.

The core idea is simple: the model can suggest, but Laravel must authorize. Policies give you a familiar, testable, domain-aware place to enforce that rule. If you already use them for controllers, extend the same discipline to your agents. The agent becomes more useful precisely because it is constrained by the same rules as every other part of your application.

Post a Comment

Previous Post Next Post