{"slug": "letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see", "title": "Letting an AI agent follow a relation without widening what it can see", "summary": "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.", "body_md": "Once an agent can read a model, it wants the neighbours. Show me the ticket, and its comments, and who wrote them.\n\nThe 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.\n\nA relation must not widen exposure.\n\nExpanding `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.\n\nSo 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.\n\nEverything else follows from holding that line.\n\nIf the target is not a registered resource, the call is refused:\n\n```\nTicket declares \"notes\" as traversable but PrivateNote is not a registered\nresource. A relation is not a way to reach a model that was never exposed:\nthe target carries its own field list and its own policy, and without them\nthere is nothing to enforce.\n```\n\nThis 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.\n\nThere 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:\n\n```\n#[AgentResource(fields: ['id', 'name'], abilities: [])]\nclass User extends Authenticatable {}\n```\n\nRegistered, 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.\n\nThat falls out of composing two existing ideas, with no \"relation-only\" concept invented for it.\n\nAn expanded record does not itself expand relations.\n\n``` php\n$resource->get($user, $id, include: ['comments']);\n```\n\nThe 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.\n\nThat 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.\n\nDot notation asking for two hops at once is refused for the same reason.\n\nA 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.\n\nA 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.\n\nAuthorizing per record means a policy check per record, which is unavoidable and correct. Issuing a query per record is not.\n\nRelations 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.\n\nThe names are validated against the declared list before they reach `with()`, so nothing arbitrary is passed to Eloquent.\n\nBad relation configuration is caught at deploy time:\n\n``` php\n$problems = app(Registry::class)->verify();\n```\n\nThat 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.\n\nThe 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:\n\n``` php\nagent 41 ticket.get -> ok (1 returned, 0 denied) via comments[2]\n```\n\n[catidegla/laravel-agent-kit](https://github.com/catidegla/laravel-agent-kit). 58 tests, PHP 8.2 to 8.4.", "url": "https://wpnews.pro/news/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see", "canonical_source": "https://dev.to/catidegla/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see-3o8l", "published_at": "2026-09-09 17:10:16+00:00", "updated_at": "2026-09-09 17:19:09.796786+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see", "markdown": "https://wpnews.pro/news/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see.md", "text": "https://wpnews.pro/news/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see.txt", "jsonld": "https://wpnews.pro/news/letting-an-ai-agent-follow-a-relation-without-widening-what-it-can-see.jsonld"}}