cd /news/ai-agents/letting-an-ai-agent-follow-a-relatio… · home topics ai-agents article
[ARTICLE · art-124863] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

Letting an AI agent follow a relation without widening what it can see

A developer detailed a security-focused approach to letting AI agents traverse related data models without widening access. The design ensures that expanding a relation composes existing exposures, never creating new ones, and requires each hop to be a separate, authorized tool call. The implementation also validates relation names, enforces per-record policies, and uses eager loading to maintain performance.

by read4 min views3 publishedSep 9, 2026

Once an agent can read a model, it wants the neighbours. Show me the ticket, and its comments, and who wrote them.

The naive implementation is ->with($request->get('include')), which is a full database export for anyone who asks nicely. Eloquent will follow any relation name you hand it, and from ticket you can reach user, and from user you can reach orders, and from there everything.

A relation must not widen exposure.

Expanding comments returns exactly what Comment declares in its own attribute, checked against Comment's own policy, for the same signed-in user. Not the parent's field list, not the parent's policy, and not a special relation mode with looser rules.

So a relation composes two exposures that already existed and never creates a third. Whatever an agent can see through ticket.comments, it could already have seen by asking for comments directly, given a tool for that.

Everything else follows from holding that line.

If the target is not a registered resource, the call is refused:

Ticket declares "notes" as traversable but PrivateNote is not a registered
resource. A relation is not a way to reach a model that was never exposed:
the target carries its own field list and its own policy, and without them
there is nothing to enforce.

This closes the obvious back door. Without it, relations: ['notes'] on one model quietly publishes a table nobody put in the exposure list, and the pull request that did it shows one word.

There is a neat consequence. To make a model reachable through relations only, without a tool that can enumerate it, register it with no abilities of its own:

#[AgentResource(fields: ['id', 'name'], abilities: [])]
class User extends Authenticatable {}

Registered, so exposure stays visible in the one config file where it belongs. No tools generated, so nothing can list your users. Reachable as ticket.author, returning two fields, checked against the user policy.

That falls out of composing two existing ideas, with no "relation-only" concept invented for it.

An expanded record does not itself expand relations.

$resource->get($user, $id, include: ['comments']);

The comments come back. Their relations do not. If the agent needs the next hop it makes another tool call, which is separately authorized and separately recorded in the audit trail.

That is a real constraint and I think it is the right one. Depth limits are the usual answer and they are a slider people turn up. Making each hop a separate call keeps each hop visible, and keeps "what did the agent read" answerable instead of turning it into one enormous nested payload.

Dot notation asking for two hops at once is refused for the same reason.

A to-many relation needs its own ceiling. A ticket with 4,000 comments is a full table dump reached through a relation. The cap comes from the target's declared maximum, not the parent's, and truncation is reported instead of silently trimmed.

A to-one the viewer cannot see returns null. There is a test for this: Alice reads her own ticket and asks for escalatedTo, which points at Bob. author resolves because she may see herself. escalatedTo comes back null. A declared relation does not make its contents hers to read.

Authorizing per record means a policy check per record, which is unavoidable and correct. Issuing a query per record is not.

Relations are eager loaded, so expanding across a page of results costs one extra query per relation instead of one per row. There is a test asserting the query count, so a future refactor that drops the eager load fails instead of quietly getting slow.

The names are validated against the declared list before they reach with(), so nothing arbitrary is passed to Eloquent.

Bad relation configuration is caught at deploy time:

$problems = app(Registry::class)->verify();

That reports a relation declared with no method behind it, and a relation pointing at a model that is not registered. Run it in a test and a misconfiguration fails the build.

The audit trail records the ids reached through each relation, because "what did the agent read" gives the wrong answer in exactly the interesting case if it stops at the parent:

agent 41 ticket.get -> ok (1 returned, 0 denied) via comments[2]

catidegla/laravel-agent-kit. 58 tests, PHP 8.2 to 8.4.

── more in #ai-agents 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/letting-an-ai-agent-…] indexed:0 read:4min 2026-09-09 ·