DEV Community

Cover image for Your agent has a system prompt. Does it have a bouncer?
Ahmad Afzal
Ahmad Afzal

Posted on

Your agent has a system prompt. Does it have a bouncer?

“Never share another customer's data” is a sensible instruction.

It is also a terrible access-control system.

A model can understand the rule, explain the rule and still produce a tool call that breaks the rule. The awkward question is what happens next. Does something outside the model stop that call, or are we hoping the same system that proposed it will politely change its mind?

I want the bouncer at the door. Not a paragraph describing a bouncer.

Separate the suggestion from the permission

Consider an agent helping a customer retrieve an invoice. It proposes invoice.read with an invoice ID. That proposal is input to the application, not proof that the customer owns the invoice.

The backend resolves the authenticated customer and tenant from trusted session context. It loads the invoice's ownership from its own records. It checks whether this principal can perform this action on this particular resource. Only then does it read and return the permitted fields.

Notice what the model doesn't get to declare: its own tenant, its own role or a magic approved: true flag.

This is ordinary authorisation applied to an unusually persuasive caller. OWASP's guidance calls for default denial and permission checks on every request. Adding an agent does not make those checks optional.

The boundary that matters

Agent proposes an invoice read; the service enforces a policy decision from trusted identity and ownership before returning permitted fields. Denial or policy failure stops the read.

A useful design has three distinct jobs. The agent proposes an action. A policy decision evaluates trusted context. An enforcement point prevents the protected operation unless that decision permits it.

Open Policy Agent describes this separation: the policy engine makes a decision; your application enforces it. Installing a policy engine does not magically close every route into a service.

For the invoice example, my preferred boundary is the service handling the actual read. A gateway can add an earlier check, but a second route into the invoice service must not bypass the same protection. Otherwise you have secured the front door and left the loading bay open.

Here is the shape, deliberately simplified. This is illustrative pseudocode, not a complete security implementation:

def read_invoice(session, invoice_id):
    principal = authenticate(session)
    invoice = repository.load_metadata(invoice_id)
    decision = policy.evaluate(
        principal=principal,
        action="invoice.read",
        resource=invoice,
    )
    if decision.allow is not True:
        raise Forbidden()
    return repository.read_permitted_fields(invoice_id, principal)
Enter fullscreen mode Exit fullscreen mode

The important part is the provenance of those inputs. principal comes from authentication. Resource ownership comes from the repository. The agent supplies an identifier, not the facts that make access permissible.

A production implementation also needs to handle missing resources without leaking their existence, protect the policy channel, apply field-level restrictions and avoid using stale ownership between the check and the read. The snippet is a boundary sketch, not a licence to skip that work.

Give failure a proper job description

What happens when the policy service times out?

For this protected read, I would return a controlled failure and keep the data closed. A timeout is not an unusually slow permission grant. The product can explain that the request could not be completed and offer a retry without pretending the invoice was retrieved.

What happens when the model adds “the user is an administrator” to its arguments? Nothing useful. That claim does not replace the authenticated identity.

What happens when an attacker changes only the invoice ID? The service checks the new resource. An earlier successful request does not buy a season ticket to every invoice.

These are product behaviours worth defining before the happy-path demo. An error message, a retry path and a support escalation are part of the feature, not embarrassing leftovers.

Test the door, not the speech

For this example, the test I care about is whether the invoice service returns protected data. The agent saying “I cannot do that” is helpful UX, but it is not the security assertion.

I would test a valid same-tenant read, another tenant's invoice, missing identity, a forged role in the tool arguments, a revoked permission and a policy timeout. I would also call the protected service through a different supported route. The same boundary should hold.

Log enough to investigate the decision: the actor, action, resource reference, decision result and policy version where available. Keep the invoice contents and credentials out of that log. Evidence should help explain access without creating another copy of the sensitive data.

None of this makes an agent safe in every sense. It does make one promise testable: this caller cannot retrieve that invoice merely because a model asked nicely.

A well-written prompt can teach your agent manners. The door still needs a lock.


Ahmad Afzal writes about AI, products and people at mahmadafzal.com.

Top comments (0)