Identity Comes From the Session, Not the Model's Arguments
The model may pick an order_id - that's its job. But it must not supply actor_id, tenant, or an access level: those values are formed by the server from a verified session and closed over inside the handler, where the model can't reach. The difference is fundamental: order_id is intent, and identity is a right, and a right doesn't come from probabilistic text.
In code this means the handler receives the trusted context as a separate argument, and the database query is always narrowed by actor and tenant.
type TrustedToolContext = {
actorId: string;
tenantId: string;
locale: "ru" | "en" | "uz";
traceId: string;
};
const orderReadInput = z.object({
order_id: z.string().min(6).max(40)
}).strict();
async function orderRead(
raw: unknown,
context: TrustedToolContext
) {
const input = orderReadInput.parse(raw);
const order = await orders.findOne({
id: input.order_id,
actorId: context.actorId,
tenantId: context.tenantId
});
if (!order) return { found: false };
return {
found: true,
order_id: order.id,
status: order.publicStatus,
refundable: order.refundable
};
}This very construction defuses the most common attack on agents.
Prompt injection gains no new rights. The user's text or a RAG fragment may ask to ignore the rules, change the tenant, or call a hidden function. But if the handler doesn't accept those fields from arguments and checks ownership in the query, such an instruction stays just text - it doesn't become permission.
To feel out a tool's risk class and what it requires, run it through the classifier.
Tool risk classifier
Class: read. Executed after server-side authorization.
The tools are described and protected by rights. Now we wire them into a loop - and the main requirement for it is to be short and observable.
Where does actor_id come from on a tool call?