Reading an Order Starts With an Ownership Check
Even a read-only tool exposes personal data, so reading an order starts not with the query but with an ownership check. The database query always includes actor and tenant from the trusted session, and the response is minimized to the fields the agent actually needs. The model names the order_id; everything else is decided by the server.
async function findPublicOrder(
orderId: string,
context: TrustedToolContext
) {
const row = await db.order.findFirst({
where: {
id: orderId,
customerId: context.actorId,
tenantId: context.tenantId
},
select: {
id: true,
publicStatus: true,
deliveredAt: true,
currency: true,
refundableSubtotal: true,
version: true
}
});
return row ? { found: true, order: row } : { found: false };
}Two details here save you from leaks. First: a non-existent and someone else's order id return the SAME public result { found: false } - otherwise the tool turns into an oracle for enumerating other people's orders. Second: even if the user named an email or phone in the chat, identity matching is done by your verified auth flow, not by the model from the text.
The rate limit too must account for actor and tenant, not just IP.
- Limit the number of distinct order ids in a short period.
- Record denied lookups without a sensitive payload.
- Don't return the internal fraud status or operator comments.
- Don't let the model build arbitrary SQL, GraphQL, or filters.
The order is read safely. The riskiest action - a refund - we deliberately don't hand to the agent whole, but split into three steps.