Chapter 8

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.

TypeScript
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.

Interactive lab 2

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.

Knowledge check

Where does actor_id come from on a tool call?

Links