Tool Use with Least Privilege
Least-privilege tool registries: typed schemas, role ACL, timeouts, idempotency keys, and redacted logs—before a model can touch CRM, SQL, or Pix rails.
A tool is a function the model is allowed to request, not a wish it is allowed to fulfill. The registry, the schema, the ACL, and the handler are the product. The model only proposes a name and arguments. ReAct is the loop around that proposal. This pattern is the privilege boundary inside each hop.
Hosted APIs made the proposal typed. OpenAI function calling, Anthropic tool use, and Gemini function calling all return structured calls. That is necessary and not sufficient. None of them will refuse a refund because the caller is a support intern, or attach an idempotency key, or redact the CPF in the log.
Context
Default-deny tool registries exist because the moment an agent can search tickets it will be asked to update them. Overlapping tools confuse routers. Missing schemas let the model invent amounts. Missing ACL lets a low-privilege session call a high-privilege handler because “the model decided.”
The moment an agent can search tickets it will be asked to update them. The moment it can read a balance it will be asked to move money. Teams then paste twenty tools into the system prompt and call it an agent. Overlapping tools confuse routers. Missing schemas let the model invent amounts. Missing ACL lets a low-privilege session call a high-privilege handler because “the model decided.”
The OWASP Top 10 for LLM applications lists excessive agency and insecure plugin design as first-class failures. In a Brazilian deploy those failures are also LGPD and payments incidents: a tool that dumps a customer row into the next prompt is a data path, not a convenience. See the Portugal–Brazil AI corridor and tool contracts for agents that touch money.
Two more facts change the registry. First, regional versus global inference does not change the handler—your SQL still runs in your VPC—but it does change what you may put in arguments and logs. Second, Model Context Protocol is how teams now share tools across IDEs and runtimes. MCP does not grant least privilege for you. A server that exposes “run_sql” to every client is the same sprawl with a nicer schema. Treat MCP tools as you treat internal handlers: named, typed, scoped, timed out.
The pattern
Default deny. Publish a small orthogonal registry. Every tool has a JSON Schema, a role ACL, a timeout, an idempotency story, and a redaction policy. Validate arguments before the handler. Return observations as data. High-risk classes leave through human-in-the-loop, not through a more polite prompt.
Model proposes call
→ schema validate (unknown fields denied)
→ role ACL
→ risk class
├ high → HITL
└ low → handler + timeout + idempotency
→ redacted observation back to the model
Keep the registry orthogonal
Prefer search_tickets, get_payment, draft_reply over a single do_anything. Overlap is how routers thrash and how you lose the audit trail. Descriptions should state when not to call the tool. Version the registry like an API; retiring a name is a deploy, not a prompt edit.
Schema is the contract
Arguments are JSON Schema or an equivalent (Pydantic, Zod). Reject unknown fields. Constrain enums. Do not accept a free-text “amount” when you need a decimal and a currency. Pair this with structured output that fails closed on the model side so the proposal is already shaped before your validator runs.
When to use
Use a typed registry as soon as the product leaves “answer from the prompt.” That includes read tools: a search that ignores tenant ACL is still a leak. Use HITL on money, identity, and production classes. Do not expose a tool because a demo needed it once. Do not give the model a shell.
- Ship now if any handler can write and you cannot name the ACL and the idempotency key.
- Ship now if tool I/O is logged in full on a corpus that can contain CPF or health-adjacent text.
- Defer MCP until the same registry rules apply to every server you connect.
Implementation notes
One dispatcher. No handler imports from the model layer. The sketch is the minimum before a Lusophone team puts write tools on a regional or global model—see cost-aware model routing for where the weights run; this page is what they are allowed to call.
Dispatcher
TOOLS = {
"search_tickets": Tool(schema=Search, acl={"agent","lead"}, cls="read", timeout=5),
"issue_refund": Tool(schema=Refund, acl={"lead"}, cls="money", timeout=10),
}
def dispatch(user, name, raw_args, task_id, step) -> Observation:
tool = TOOLS.get(name)
if tool is None or user.role not in tool.acl:
return Observation(denied="acl")
args = tool.schema.model_validate(raw_args) # fail closed
if tool.cls in ("money", "production", "identity"):
return interrupt(user, name, args)
key = idempotency_key(task_id, step, name, args)
if seen(key):
return load(key)
with timeout(tool.timeout):
out = tool.handler(args, ctx=user)
return persist(key, redact(out, policy=tool.redact))
Timeouts, keys, and logs
Every handler gets a timeout. Hung SaaS calls are how loops blow the spend cap. Idempotency keys belong on writes even if the vendor API is “probably safe.” Logs store tool name, latency, decision, and redacted argument hashes—not raw payloads. That is the same discipline as eval gates for shipping.
Failure modes
- Prompt-only ACL. “You may not refund.” The handler still runs. Enforce in process.
- One mega-tool. The model stuffs a SQL string. You have given it a shell.
- Schema as documentation. You describe fields and never validate. Unknown keys sneak through.
- Retry without keys. Two Pix. Ordinary API hygiene; agents make it mandatory.
- Observation as system. Tool output changes policy. Delimit it; the ReAct article covers the loop.
- MCP sprawl. Every desktop server becomes production. Inventory them as subprocessors if they see personal data.
If tools are not typed and bounded, you do not have an agent. You have a language model with your credentials. Walk that distinction in evaluate an AI stack for LatAm.
Trade-offs
A tight registry reduces capability theater and increases “the model cannot do that yet” tickets. That is the point. Adding tools is a product change: schema review, ACL, evals, and a rollback plan. Teams that treat the registry as a prompt file will ship the incident first and the table later.
Strict schemas raise refuse rates when the model omits a field. One repair loop is cheap. An unbounded repair loop is the same as no cap. Fail closed to the user with a reason they can act on.
- Small orthogonal registry — Predictable routing and audits. More tools to version.
- Process ACL + schema — The model cannot talk past policy. False denies on missing fields.
- HITL on write classes — No unsupervised Pix. Pending state in the product.
- MCP without inventory — Fast local demos. Untracked processors and shells.
A tool is an API with a model on the caller side. The registry, the schema, and the handler are the product.
What to do next
- List every handler in Git with schema, ACL, class, timeout, and redact policy.
- Fail closed on unknown tools and unknown argument fields.
- Put idempotency keys on every write before the next ReAct loop can double-call.
Published by Janeiro.ai. Original editorial for operators. How this was made