Toolsleast privilegescopesdelegated authconfirmationsandboxing

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.

Interview question
Progress

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_request to 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_service in 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).

Least-privilege dispatch path
noyesyesnoModel emits tool callTool in allow-list for this task?Validate argumentsDeny → observation to modelIrreversible?Ask user to confirmExecute with user-scoped token in sandboxAppend to audit log
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Tool registry entries carry scope, risk class and concurrency; the dispatcher enforces them.
1from dataclasses import dataclass
2from typing import Callable, Literal
3
4@dataclass(frozen=True)
5class ToolSpec:
6 name: str
7 fn: Callable
8 scope: str # e.g. "tickets:read"
9 risk: Literal["read", "write", "irreversible"]
10 max_parallel: int = 4
11
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 approval
26 result = spec.fn(call["arguments"], ctx) # ctx carries the user-scoped token
27 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

Use it when
  • 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.
Avoid it when
  • 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_request to 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_sql exposed "just for analytics" executes a DELETE composed 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

Complexity
low → high
Latency
low → high
Cost
low → high
Reliability
poor → strong
Debuggability
hard → easy

Upfront design work; near-zero runtime cost; the only real defense against tool misuse.

Don't delegate understanding
The manifesto →