Here is a Laravel feature that takes five lines:
use Prism\Prism\Facades\Tool;
$lookupOrder = Tool::as('lookup_order')
->for('Fetch an o...
For further actions, you may consider blocking this person and/or reporting abuse
@mark_boyko_1a6cae69fd43d7 that read versus write split is the right instinct and there is one edge case that breaks it which I wish I had put in the article. A read only agent can still write outbound if you render its answer as markdown. Injected text tells the model to end its reply with an image pointing at an attacker domain and to put the order data in the query string. The victim browser fetches it the moment the reply renders. No tool call, no side effect, nothing for a policy to intercept and your logs show a perfectly well behaved read only agent. So the output side is an attack surface too, not just the tools. What helps is rendering agent replies as plain text or allowlisting the hosts for any links and images before you render them, plus a strict img-src in your CSP so the browser refuses the request even if something slips through. This is also the other half of what @sika_vasia_c04f8b19da4964 asked about multi tenant because the transcript itself is the leak, not only the tools attached to it.
That is a nasty edge case 😁 I hadn't considered the browser rendering itself as an outbound channel. It really shows that securing the tools is only half the problem. 🙃
That is a really good point. I was thinking about side effects mainly in terms of tools but the rendered output itself can become a side channel. The markdown example makes it very clear why output sanitization and CSP need to be part of the agent security model too!
It's striking how the fix you describe converges with what capability/security people have been saying since Hardy: don't hand the deputy ambient authority, hand it the caller's identity.
We've been working on this exact problem but on distributed-agent side. A few of your points are things we tried to solve by design rather than by discipline:
"Move the Gate inside the tool" → we enforce this cryptographically at the execution door: ephemeral, gateway-signed delegation tokens (PASETO) with parameter lockdown, verified offline by the tool server. An injected agent can decide anything — without a valid token scoped to that function and those args, nothing runs.
Allowlists → we go one step further: capabilities outside an agent's authorized channels are filtered out of the discovery index itself, so a compromised agent gets "capability not found". It can't even learn the tool exists.
App-level credentials → cognitive agents are stateless and hold no DB drivers at all; only isolated tool servers touch the transactional layer.
Two open questions I'd love your take on: how do you propagate the end user's authorization scope (your $actingUser) once agents start delegating to other agents across trust boundaries?
And where does the policy live when there's no request lifecycle to hang it on — at token-minting time, or inside the token as claims?
Now I'll be thinking how do "attach" the user to the conversation... may be with User ID assigned in the company, and addint this ID to the DET token...
The cryptographic enforcement is the grown up version of what my article does with discipline, so thank you for laying it out. On propagation: within one app I just carry the acting user explicitly through the call chain, but across trust boundaries I would reach for token exchange in the on behalf of style, where each hop trades its token for a narrower one and delegation can only attenuate, never widen. That also answers your user id idea: put identity in the token, yes, but carry the narrowed scope with it, because identity alone recreates ambient authority one hop later, the receiving side knows who asked but still acts with its own full power. On where the policy lives, I would split it: minting time answers whether this delegation may exist at all, and the execution door keeps the final check, since claims go stale the moment they are signed and only the resource side knows about revocation and current state. So the token carries the shape of the permission, and the tool server stays the authority.
In my case, it would be user_id + task to execute (agent or MCP) + parameters, signed as a DET using PASETO for better compatibility. The token is usable only once, with a short TTL.
I will follow your articles, because although they talk about PHP and I'm working in Python, I think it would be interesting not just to learn from them, but to implement these ideas in my SDK.
That shape makes sense and binding the parameters into the token is the part most designs skip. The one tension I see is single use plus offline verification. Replay protection needs shared state somewhere, a jti or nonce store every tool server can reach, so without it the short TTL is what is really doing the work rather than the one time rule. I would also add an audience claim naming the exact tool, so a token minted for one cannot be replayed at another inside that window, and keep the key asymmetric, v4.public rather than v4.local, so a verifier that gets popped cannot mint tokens for its neighbours.
You're right about the tension — true single-use needs shared state.
The other two are already in our design, for the same reasons you give: we check aud against the node_id and the expected function, and we use v4.public with Ed25519, so a verifier only holds the gateway's public key and can't mint for its neighbors.
On replay, my current take: for read-only tools the parameter lockdown makes replay idempotent — same args, same sanitized payload, nothing gained.
For mutating operations we'd need a jti cache scoped to the token TTL, or keep that class online-checked.
Did you land anywhere cleaner on that?
Nothing cleaner, no, and your split is where I land too. The one shift I would make is to move the check from the token to the write itself, an idempotency key stored alongside the mutation, since anything that mutates needs that for retries anyway. Then replay protection falls out of infrastructure you were already building instead of a cache that exists only for security. Really good thread, thanks for digging in. Ping me when the SDK is public.
This is a great reminder that prompt injection isn't just about tricking the model. Once the model has access to real tools and data, the security of the whole application becomes part of the problem. Very practical perspective!
Thanks Olivia, that is exactly the shift I hoped would land. The model being fooled is survivable, the damage comes when the deputy acts on it with real permissions. Which is why the fix lives in the application layer, scoping what the agent can touch, not in writing better prompts.
Totally agree. Once the agent has real permissions, prompt quality alone can only get you so far. I’m curious though: in a Laravel app, what do you think is the most practical way to scope an agent’s permissions without making the implementation too complex for developers?
Good question, and the nice part is that Laravel already ships the answer. Capture which user the agent is serving, then inside every tool run your existing policies as that user, the Gate facade has a forUser method exactly for checking permissions as someone other than the current session, and scope queries through the user relationship instead of querying models directly. The complexity cost is about one line per tool, because you reuse the policies you already wrote instead of building a new permission system. For anything that writes or sends I would add one more step and treat the model output as a proposal that a person or a policy confirms before it runs.
That makes a lot of sense, especially if you can reuse the policies that are already in place instead of creating a separate permission layer for the agent. I also like the idea of treating writes and sends as proposals first. How would you decide which actions should always require human confirmation and which ones are safe to let the agent handle automatically?
My rule of thumb is two questions: can you undo it, and does it leave the building. Scoped reads and internal drafts are safe to automate, because the worst case is a wrong answer you can correct. Anything irreversible or outward facing, refunds, customer emails, deletions, external API calls, starts behind confirmation, since one bad trigger there is a real incident instead of a bad draft. And I would start with everything confirmed and relax it action by action as the logs show the agent behaving, it is much easier to remove a confirmation step than to explain why it was missing.
Thanks for sharing this great article! Really enjoyed reading it and learned a lot. I have also seen how important prompt validation and security checks are when working with AI features in real projects. Keep up the good work!
Thanks, glad it was useful. Validation definitely helps, though the bigger win in my experience is keeping the tool surface small, so there is simply less for a bad prompt to reach.
Thanks!
Nice work! Really liked the confused deputy comparison here. It makes the problem much easier to reason about than treating prompt injection as just a prompt engineering issue. I especially agree with the idea that every tool should act with the users authority rather than the applications authority. I wonder how you would handle this in a multi tenant Laravel app where the agent can potentially switch between different tenant contexts during the same conversation.
Thanks, glad the framing helped. For multi tenant the first rule I would set is that the tenant is never something the model can pass in, because the moment tenant id becomes a tool argument it is just another injectable value. I bind the tenant and the acting user in the agent constructor, resolve them from the session on every tool call, and scope every query through the tenant relation instead of a global query. The harder half is the conversation itself. If the acting user or the tenant changes mid conversation, I start a fresh conversation rather than continuing, because the old transcript already holds the previous tenant data and the model will happily quote it back without calling a single tool. So the rule ends up being one conversation, one tenant, one acting user.
I can be wrong but the point about Laravel policies not automatically protecting AI tools is probably the part many Laravel developers will miss. We are so used to putting authorization in controllers and middleware that it is easy to forget that an agent tool is effectively creating a new execution path. I would be really interested to see a follow up article showing a reusable Laravel pattern for passing the acting user into every tool without having to implement the same authorization plumbing over and over
Yes, and you put it better than I did: a tool is a new execution path with no route, no middleware and no resolved user in front of it. The pattern that works for me is a small abstract base tool that takes the acting user in the constructor and runs the Gate::forUser check inside handle, then delegates to an abstract method the concrete tool implements. The agent constructor becomes the only place identity enters the system, tools() just passes it down, and no individual tool can forget the check because it never writes it. One trap worth knowing about: agent middleware in the Laravel AI SDK intercepts prompts, not tool calls, so it looks like the natural hook for this and it is not. Good idea for a follow up, it is on my list now.
That makes a lot of sense. I like the idea of keeping the identity and authorization flow in one place so individual tools cannot accidentally skip it.
One thing I have started thinking about with AI agents is that read access and write access should probably be treated very differently. A scoped database lookup is one thing, but sending emails, deleting records or making external requests can have much bigger consequences. I like your proposal of making the model produce a proposed action first and putting policy or human approval between the model and the side effect. That feels much more robust than trying to make the prompt smarter
@mark_boyko_1a6cae69fd43d7 Agreed, and read is the tier people underestimate. A read only agent can still write outbound if you render its reply as markdown. Injected text asks the model to end with an image pointing at an attacker domain and to put the data in the query string, then the victim browser fetches it the moment the reply renders. No tool call, no side effect, nothing for a policy to catch. Rendering replies as plain text and locking img-src in the CSP closes that one.
Loved the comparison with the confused deputy problem. It makes the risks of AI agents much easier to grasp, especially for developers who are new to AI security. Definitely something worth keeping in mind when building agent-based features.
Thanks! The confused deputy framing does the heavy lifting because the pattern is older than AI, and security folks already solved it once with scoped tokens and capabilities. Once you see the agent as a deputy holding your permissions, the right defenses become much easier to reason about.
Thanks for sharing!
One thing I didn't see covered: what about rate limiting and anomaly detection as a first line of defense? Even with Gate::forUser() scoping done right, a compromised or injected agent could still hammer a tool with many valid-looking calls. Did you consider per-user/per-tool rate limits as a complement to authorization, not just logging after the fact?
Fair point, and it belongs in the stack. Authorization bounds what a single call can do, but says nothing about volume and a scoped agent reading one allowed order at a time can still walk through all of them if nothing is counting. Laravel makes the mechanics cheap. The RateLimiter facade with a key built from user id plus tool name gives you per user per tool limits in a few lines and for agents I would add a budget per conversation on top, because a runaway loop spends a per minute allowance very differently than a human does. The layering matters though: limits and anomaly alerts shrink and surface the damage, while the authorization scope still decides whether the damage is possible at all.