Tool Permissions and Least Privilege
Give each tool the narrowest scope that does the job, separate reads from writes, act with the user's delegated authority, and gate destructive actions behind confirmation, sandboxes and audit logs.
Permissions are architecture, not prompting
An agent is only as dangerous as the tools it can reach with the credentials it holds. A system prompt saying "never delete customer data" is a request to a probabilistic process that also reads untrusted web pages (Indirect Prompt Injection). The credential attached to run_sql is a fact. Design the facts.
Least privilege for tools means: every tool carries exactly the scope it needs, scopes are enforced by the system that owns the resource (database roles, OAuth scopes, IAM policies, filesystem permissions), and the agent process holds nothing broader than the union of its tools' scopes for the current task. If the agent is compromised via injection, the blast radius is that union — so keep it small.
This is the same principle behind the escalation ladder in Choosing the Right Abstraction: the more autonomous the component, the tighter its authority must be. A workflow with fixed steps can be trusted with more because its actions are enumerable; an open-ended agent cannot.
Per-tool scopes and read/write separation
Split tools along the read/write boundary and give them different credentials. search_tickets uses a read-only token; update_ticket uses a write token restricted to the fields it may change; there is no run_sql. Read tools can be offered freely and retried freely; write tools get validation (Argument Validation), idempotency (Idempotency) and possibly confirmation.
Narrow the write tools further by design rather than by argument checks: close_ticket(id) instead of update_ticket(id, {status: "closed"}), refund_order(id, reason) with a server-side cap instead of transfer_money(from, to, amount). A tool whose worst case is bounded by its signature needs far less policy around it.
- Read: no side effects, safe to retry, minimal review. Still scope to the tenant.
- Idempotent write: bounded, reversible-ish (
set_status); logged; retry with key. - Irreversible write:
send_email,refund,delete; confirmation or approval gate; never auto-retried. - Escape hatches (
run_sql,exec_shell,http_requestto arbitrary hosts): remove, or sandbox with an allow-list and treat as irreversible. - Expose only the tools the current task needs; a support bot does not need
deploy_servicein its list even if the platform offers it.
User-delegated authority
The agent should act as the user, not as a super-user. Acquire a token scoped to the user (OAuth on-behalf-of flow, a session token, a short-lived STS credential) and pass it into every tool call in the context object. The resource server then enforces the same row-level and object-level rules it enforces for the user's own clicks — an agent that asks for another tenant's order gets a 403 from the database, not from your regex.
A shared service account with global access is the single most common and most damaging shortcut. It turns every prompt injection into a full-privilege escalation and makes audit logs say "the bot did it" instead of "the bot did it on behalf of alice@". Where a service account is unavoidable (batch jobs), scope it to the job and rotate it.
Credentials never enter the context window. The tool receives them from ctx, not from an argument the model could read, log, or echo back (Secrets and Untrusted Output).
Confirmation, sandboxing and audit
Confirmation for destructive tools. Tag each tool with a risk class, and route irreversible ones through a human step: pause the run, show the exact call (refund_order(id=ORD-8812, amount=€240)), and resume only on explicit approval. Human-in-the-Loop Overview and Approval Gates and Risk Classes cover how to decide which actions need a gate and how to keep gates from becoming rubber stamps. The pause must be a real suspension of the run with persisted state, not a "please confirm" message the model can talk itself past.
Sandboxing. Anything that executes code or touches a filesystem runs in an isolated environment: a container with no network (or an egress allow-list), a read-only root, a scratch directory, CPU/memory/time limits, and no ambient credentials. The tool returns the stdout and the diff; the host decides what to apply. Browser and HTTP tools get a domain allow-list so an injected page cannot make the agent POST data to an attacker (Tool Misuse and Data Exfiltration).
Audit logs. Every tool call is appended to an immutable log: who (user + run id), what (tool, arguments, result hash), when, which policy path it took (allowed / confirmed / denied), and which token scope was used. This is the same data your traces carry (Tracing Agents) but with a different retention and access policy. When something goes wrong, the question "what did the agent actually do, and with whose authority?" must have an exact answer.
1from dataclasses import dataclass2from typing import Callable, Literal3 4@dataclass(frozen=True)5class ToolSpec:6 name: str7 fn: Callable8 scope: str # e.g. "tickets:read"9 risk: Literal["read", "write", "irreversible"]10 max_parallel: int = 411 12REGISTRY = {13 "search_tickets": ToolSpec("search_tickets", search_tickets, "tickets:read", "read", 8),14 "close_ticket": ToolSpec("close_ticket", close_ticket, "tickets:write", "write", 1),15 "refund_order": ToolSpec("refund_order", refund_order, "orders:refund", "irreversible", 1),16}17 18def dispatch(call, ctx):19 spec = REGISTRY.get(call["name"])20 if spec is None or spec.name not in ctx.task_allowlist:21 return audit(ctx, call, "denied", {"error": "tool_not_allowed"})22 if spec.scope not in ctx.user_token.scopes:23 return audit(ctx, call, "denied", {"error": "insufficient_scope"})24 if spec.risk == "irreversible" and not ctx.approvals.get(call["id"]):25 raise NeedsApproval(call) # suspends the run; resumes on approval26 result = spec.fn(call["arguments"], ctx) # ctx carries the user-scoped token27 return audit(ctx, call, "executed", result)Key points
- Enforce permissions in the systems that own the resources, not in the prompt.
- Separate read and write tools with different credentials; design write tools with bounded signatures.
- Act with a user-scoped token so existing authorization rules apply to the agent.
- Tag tools by risk class and gate irreversible ones behind a real, persisted approval step.
- Sandbox code execution and filesystem access; allow-list network egress.
- Audit every call with actor, arguments, policy path and scope used.
- The blast radius of a compromised agent equals the union of its tools' scopes — keep it small.
When to use — and when not to
- Any agent with write access to real systems, from day one — retrofitting is far harder.
- Agents that read untrusted content (web, email, documents) and also hold write tools.
- Multi-tenant products where one user's agent must never see another's data.
- Regulated domains where "what did it do and on whose behalf" must be answerable.
- Treating confirmation prompts as the only control — users approve reflexively; scope the tool too.
- A single global service account "for simplicity" during the prototype that ships to production.
- Relying on the model's judgment about whether an action is destructive.
- Sandboxes with unrestricted egress — exfiltration needs only one open port.
Failure modes
- Injected web page makes the agent call
http_requestto post the user's documents to an external host. - Shared service token lets a support agent read every tenant's tickets via an IDOR-style argument.
run_sqlexposed "just for analytics" executes aDELETEcomposed from user text.- Confirmation implemented as a model-visible message; the model "confirms" on the user's behalf.
- Audit log records the tool name but not arguments; the incident cannot be reconstructed.
- Sandbox mounts the host home directory read-write; a code tool edits
~/.ssh.
Tradeoffs
Upfront design work; near-zero runtime cost; the only real defense against tool misuse.