---
title: The Agent Borrows Your Permissions
date: '2026-10-21'
description: 'One AI agent, five people with different rights. Sumalos limits the agent''s tools to what the person talking to it could do in the app.'
author: Pablo Fernandez
tags:
  - AI
  - Agents
  - Permissions
  - Tool Calling
  - Security
  - Sumalos
source: 'https://www.pablofr.com/blog/tool-permissions-for-ai-agents'
---
The sentence is "issue María Carmona's invoice". It reaches Suma, the assistant in [Sumalos](/blog/pixel-art-ai-assistant), five times in one morning. The owner of the business types it on the web. So does an employee who may look at invoices but not touch them. Ana, from the accounting firm that reviews the books, types it too. Then it arrives twice on WhatsApp: from a number the business set up to send receipts, and from a number that belongs to nobody with an account.

Same model, same instructions, same words. Only the owner should end up with an issued invoice, and each of the other four should get a clear no with the reason.

The obvious way to build that is to hand each person a different set of tools: the owner gets `issue_invoice`, the employee doesn't, and a tool the model never sees is a tool it can't call. It's a good instinct, and Sumalos mostly doesn't do it. The question this post answers is where the difference between those five people lives instead, and why it can't live in the prompt.

## Who is asking is never a tool argument

The first rule is the one every other check leans on. The model never says who the person is, which organization they're in, or what they may do. The server works that out before the model runs, from things the model can't touch: the session cookie on the web, the sender's phone number on WhatsApp.

The result is one object, `AiAccess`, built per request and handed to every tool as its first argument:

```ts:packages/server/src/ai/data/access.ts
export type Principal = { kind: "member"; userId: string } | { kind: "phone"; phone: string; userId: string | null };

export interface AiAccess {
  principal: Principal;
  /** The organizations this request may touch: one on the web, those the number may use on WhatsApp. */
  organizationIds: string[];
  permissions: Record<string, OrganizationGrant>;
  trace?: AccessTrace;
  audience?: "assistant" | "person";
}
```

Each `OrganizationGrant` lists the areas the principal may read and write there (Contacts, Invoices, Quotes, Catalogue, Expenses) and whether they're an owner or admin. Two constructors fill it, one per channel:

```
  web                              WhatsApp
  ┌───────────────────────┐        ┌───────────────────────┐
  │ session's user        │        │ sender's number       │
  │ page's organization   │        │ agent_phones rows     │
  │ resolveAccess()       │        │ resolveAccess()       │
  └───────────┬───────────┘        └───────────┬───────────┘
              │ memberAccess                   │ phoneAccess
              ▼                                ▼
  ┌────────────────────────────────────────────────────────┐
  │ AiAccess: principal, organizations, areas per org      │
  └────────────────────────────────────────────────────────┘
```

*Both channels end in the same object, and both ask the same resolver the web's own handlers ask.*

`resolveAccess` is the single function the app uses to decide what anyone may do in an organization, so the assistant can't drift from the screens. It's also what makes Ana work without special cases. She isn't a member of the business; she reaches it through a grant to her firm, and the resolver hands back role "member" with the grant's areas, capped by its preset (Read only and Reviewer top out at "view"), plus a `via` that says which firm she came through.

WhatsApp adds a second key. A business can authorize a phone number with its own table of permissions per area (view, add, edit, delete), and when that number belongs to a member, each action needs both:

```ts:packages/shared/src/agent.ts
export function effectiveAgentPermissions(agent: AgentPermissions, member: { role: "owner" | "admin" | "member"; permissions: Partial<Record<Area, Access>> }): AgentPermissions {
  const memberHas = (area: Area, need: "read" | "write") => {
    if (member.role !== "member") return true;
    const access = member.permissions[area] ?? "none";
    return need === "read" ? access === "read" || access === "write" : access === "write";
  };
  return Object.fromEntries(
    AREAS.map((area) => [area, Object.fromEntries(AGENT_ACTIONS.map((action) => [action, !!agent[area]?.[action] && memberHas(area, action === "read" ? "read" : "write")]))]),
  ) as AgentPermissions;
}
```

An owner whose number is set to "only send documents" can do less by WhatsApp than at the desk, on purpose: the business decides what each phone may do, separately from what each person may do. The agent process reads all of this again on every message, so removing a number or switching WhatsApp off takes effect on the next one.

## A handful of tools instead of fifty

Here is where Sumalos parts with the obvious design. The catalogue of things Suma can do has 53 actions today: create a draft, issue it, send it, record a payment, merge two contacts, archive a catalogue item. None of them is a tool. The model sees a handful of generic tools, and the catalogue sits behind one of them:

| Tool | What it does |
| --- | --- |
| `buscar`, `consultar`, `leer` | Search, query and read records |
| `describir` | The action catalogue for this person, or one action's input schema |
| `actuar` | Run one action or a plan of up to ten steps |
| `adjuntar` | Take documents from the conversation to a record |

A per-person tool list was on the table. The [AI SDK's tool search](https://ai-sdk.dev/docs/ai-sdk-core/tool-search) lets you defer tool definitions and load them mid-answer, and I evaluated it. It didn't fit for two reasons written down in the design notes. Loading tools mid-answer costs an extra model step, and an answer has a step budget (six). And a tool list that changes between people, or between turns, changes the start of the prompt, which is the part [prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) reuses. A stable list of primitives keeps the prefix identical for everyone, and `describir` gives the same information on demand, with one thing a search by name can't know: whether this person may run it here.

So the tool list does change, but by capability, not by permission:

```ts:packages/server/src/ai/tools/index.ts
const base = {
  buscar: buscarTool(env),
  consultar: consultarTool(env),
  leer: leerTool(env),
  saber: saberTool(env),
  ...(env.web ? { buscar_en_internet: internetTool(env) } : {}),
  ...(env.actions ? { actuar: actuarTool(env), adjuntar: adjuntarTool(env), describir: describirTool(env), programar: programarTool(env) } : {}),
  ...(recall ? { recuperar: recuperarTool(env) } : {}),
};
```

`env.actions` is the action gateway, and it's only wired where acting is possible at all. On WhatsApp that means a number with a user account behind it. The number nobody owns gets no `actuar`, no `describir`, no `adjuntar`: for that one person the tools really don't exist, and the instructions say so ("you can't create, change or delete anything yet; don't offer it"). That's the one case where removing tools is the right call, because the answer is no for every action and will stay no for the whole conversation.

For everyone else, the reads are narrowed further down, by the data layer and Postgres row-level security (that's [its own post](/blog/privacy-filters-for-ai-agents)). The writes are narrowed at the gateway.

## What the model is told

The model still needs to know what it may do, or it will spend its steps proposing things that get refused. Three pieces of text tell it.

The instructions say actions run "with the person's permissions and never more". On WhatsApp they add a line built from the number's table, such as "What this number may do: in «Estudio Demo», Facturas: ver, añadir; Gastos: añadir. Don't do or promise anything outside that." Organization names go between «» because they're data a customer typed, not instructions.

`describir` marks every action. The permitted ones come in full; the rest, by name only:

```ts:packages/server/src/ai/tools/actions.ts
const line = (a: ActionDescription) => `- ${a.name}: ${a.summary} [${a.risk === "confirm" ? "pide confirmación" : "al momento"}${a.undoable ? ", se puede deshacer" : ""}; ${permissionWords(a.permission)}${a.allowed ? "" : "; NO permitido a esta persona"}]`;
```

Listing the denied ones by name is deliberate. If the employee asks to issue an invoice and the action had vanished from the catalogue, the model would have nothing to explain the refusal with, and the likely outcome is a confused paraphrase or a made-up workaround. With the name and "not permitted to this person" in hand, it can say exactly that.

For Ana, the context block changes "You're talking to" into a sentence about her firm and her access, and for Read only and Reviewer it adds: no changes and no actions, explain that her access doesn't allow it.

All of that is advice. The design notes put it in one line: what Ana may do is "applied by the resolver, not by the text". A model that ignores its instructions, or a prompt injection in a scanned receipt, changes nothing below.

## Checked when it's proposed, and again when it runs

Every call to `actuar` goes through `propose`, in core-api, next to the same domain functions the web's handlers call. The order of the checks is the design:

```
  actuar(pasos)
     │
     ▼
  allowlist ──────── classified action? kill switch off?
     │
     ▼
  actorFor ───────── membership, number, grant: read now
     │
     ▼
  actorAllows ────── per step, before its input is parsed
     │
     ▼
  preview ────────── real figures, and what is missing
     │
     ├── all safe ──▶ run now
     │
     └── risky ─────▶ token, 10 minutes ──▶ the person says yes
                                                     │
                                                     ▼
                                     actorFor + actorAllows, per step
```

*The path of one plan through the gateway: permissions are read fresh before the preview and read fresh again for each step at execution.*

`actorFor` reads the person from the database every time, not from what the model, the browser or the agent process remembers: on the web the session's membership, on WhatsApp the number's user as of now, their membership, and the number's permissions with WhatsApp switched on. `actorAllows` then applies the same rule the web's guards apply:

```ts:apps/core-api/src/core/ai-actions/actor.ts
function meets(actor: Actor, p: ActionPermission, op: AgentOp): boolean {
  if (readOnlyGrant(actor.membership)) return false;
  // Configuración's kind of changes (members, permissions): owners and admins, from the web only.
  if ("manager" in p) return canManage(actor.membership) && !actor.agent;
  const areas = "area" in p ? [p.area] : p.anyOf;
  return areas.some((area) => {
    if (!can(actor.membership, area, p.need)) return false;
    if (!actor.agent) return true;
    const grant = actor.agent[area];
    return p.need === "read" ? !!grant?.read : !!grant?.[op];
  });
}
```

Each action declares what it needs and which kind of change it is, and the `op` is what maps onto the number's add, edit and delete switches. Issuing is an edit of an invoice:

```ts:apps/core-api/src/core/ai-actions/catalog/invoices.ts
name: "factura.emitir",
entity: "factura",
summary: "Emite un borrador: le da número en su serie y, con VERI*FACTU activo, lo registra en la AEAT. No se puede deshacer.",
permission: INVOICES_WRITE,
op: "update",
risk: "confirm",
input: z.object({ factura: handle("el borrador (fac_…)") }),
refs: { factura: "invoice" },
```

The permission check runs before the input is parsed. A person who can't issue invoices doesn't get "the handle `fac_7k2qpx` was not found" or a validation error that confirms a draft exists; they get the refusal and nothing about the data.

Why check again at execution, when the preview already passed? Because a risky plan waits for the person, and the token behind that wait lives ten minutes. An owner can demote the employee, or switch off the number, between the preview and the "yes". So `decide` rebuilds the actor from the database, from whoever clicked or replied (not from the access the plan was proposed under), and the run loop checks every step again. A step whose permission vanished stops with "you no longer have permission to do it", and the steps before it are reported as done. Undo goes through the same check.

## The yes the model can't say

The confirmation deserves a word, because it's a permission too: the permission to do something irreversible, and only the person holds it. No tool confirms. The token goes into the web's card or into the WhatsApp agent's own state, never into the text the model reads, and `actionOutput` scrubs anything token-shaped from that text as a second line of defence. On WhatsApp the agent's code, not the model, reads the person's next message: a strict yes ("sí", "vale, adelante", a thumbs up; at most six words, no question mark, no "pero") confirms, and anything else cancels. The token is bound to the person, the organization and the conversation, so a yes from someone else's chat doesn't count.

## A tool that can't reach the database

All of this would be decoration if a tool could open its own connection and write a row. So the tools can't. They receive a `ToolEnv` with the data module, the access, the handles and the gateway, and a test reads their source and fails on any route to the database:

```ts:packages/server/src/ai/security/boundary.test.ts
/** Tools must not even build SQL: that lives in ai/data and ai/query, behind the proxy. */
const FORBIDDEN_IN_TOOLS = [...FORBIDDEN_EVERYWHERE, /from\s+["']drizzle-orm["']/, /^import\s+(?!type\b)[^;]*from\s+["']@sumalos\/db["']/m];
```

Type imports from the database package are allowed, values aren't. The same file checks that the web chat builds its tools with `assistantTools(...)` and never passes the raw `db` into them. It's a blunt test, a regex over import lines, and that's why I like it: the day someone adds a "quick" tool that writes directly, it goes red before review.

There's one more layer above the permissions, and it doesn't care who you are. An allowlist in front of the gateway is deny-by-default (an action the registry has but the allowlist hasn't classified is refused, and a test fails until someone classifies it), and some prefixes are forbidden outright: members, invitations, roles and permissions, the tax certificate, the AI privacy policy, payment keys, deleting the organization, the agent's own settings. Even an owner can't ask Suma to give the employee write access to invoices. The permission system is the one thing the agent can never reach, because an agent that can change permissions makes every check above it advisory.

## Five people, one sentence

Back to the morning. Here is what each of the five gets for "issue María Carmona's invoice", and which line of code said so:

| Who | Where it stops | What Suma says |
| --- | --- | --- |
| Owner, on the web | Preview, then waits | The issue preview with real figures and a Confirm button |
| Employee with Invoices on "view" | `actorAllows`, before the input | "No tienes permiso para «emite un borrador…» en Estudio Demo." |
| Ana, firm reviewer | `readOnlyGrant`: any action, even read-only ones | Her access doesn't allow changes; reviewing is done in the app |
| Number set to "only send documents" | `actorAllows`: the number has add, not edit | The same refusal, ending in "desde este número" |
| Number with no account | No `actuar` tool exists | It can't change anything by WhatsApp yet |

Three of the four refusals come from one function, `meets`, and the fourth is a tool that was never built for that request. None of them depends on the model behaving.

What keeps this honest over time is a test that doesn't know any action by name. `permissions.test.ts` walks the whole registry and, for every write action, tries a read-only member, a member who writes everything except one area the action needs, owners and admins, and WhatsApp numbers with the wrong kind of change, with only "view", and with all permissions but no membership behind them. A new action gets those tests the moment it's registered.

The trade is that the model knows about things it can't do. It sees "NO permitido a esta persona" next to `factura.emitir` and has to be told not to try; sometimes it will try anyway and spend a step on a refusal. In exchange, one prompt prefix serves every person, the agent can explain a no instead of pretending the feature doesn't exist, and the line between allowed and refused sits in code that reads the database at the moment it matters, where the model can't argue with it.
