Least Privilege for LLM Agents: Tool Scopes and Side-Effect Confirmation
For developers building LLM agents that can call internal APIs, shells, databases, or deployment tooling. This guide shows how to scope each tool to the minimum permissions needed, add confirmation gates before writes or external actions, and reason about the latency and operational trade-offs.
TL;DR — If your LLM agent can call tools, treat every tool like an untrusted integration point: give it the smallest possible scope, pass resource-specific credentials instead of broad ones, and require an explicit confirmation step before any write, delete, deploy, purchase, email, or network call outside your trust boundary. The most common fix is to stop handing the agent a general-purpose shell or admin token and replace it with narrowly-scoped wrappers plus a policy gate that blocks side effects until a human or higher-trust service approves them. Reading time: ~7 min
What it is and where it sits
Least privilege for LLM agents means two things in practice:
- Tool permissions are scoped to the exact action and resource set the agent needs.
- Any tool call with side effects is blocked until a confirmation policy says "go".
This sits between the model and the systems it can affect: shell, database, ticketing, email, cloud APIs, CI/CD, payment APIs, and internal admin endpoints. Architecturally, it replaces the naive pattern where the model gets a broad API key, a full database role, or unrestricted shell access.
In a typical flow, the app sends the user request and the available tool schema to the model. The model proposes a tool call. A policy layer evaluates that call against identity, scope, environment, and side-effect rules. Read-only calls may execute immediately. Write calls are converted into a pending action that requires confirmation.
View as text
[User] ---- 1. request ----> [App] [App] ---- 2. prompt + tool list ----> [LLM] [LLM] ---- 3. proposed tool call ----> [Policy Gate] [Policy Gate] ---- 4a. allow read ----> [Tool Wrapper] [Policy Gate] ---- 4b. require confirm ----> [Approval UI] [Approval UI] ---- 5. approved action ----> [Tool Wrapper] [Tool Wrapper] ---- 6. scoped API call ----> [DB/API/Shell] [DB/API/Shell] ---- 7. result ----> [Tool Wrapper] [Tool Wrapper] ---- 8. output ----> [App]
The important architecture point: the model should not be the enforcement point. It can suggest actions, but your app or gateway must enforce scopes and confirmation. Prompt instructions like "never delete data" are not a control.
What it replaces
Common anti-patterns this replaces:
- Giving the agent a
DATABASE_URLfor a superuser role. - Exposing
kubectl,terraform, orbashdirectly. - Reusing a human admin OAuth token for agent actions.
- Letting the model send email, Slack, or webhooks without a hold/review step.
- Using one generic internal API token with
*:*permissions.
How it actually works
Walk one realistic example: a support agent that can look up orders and issue refunds.
Requirements:
- It should read order details automatically.
- It should never refund without explicit confirmation.
- It should only access orders for the current tenant.
- It should not be able to call arbitrary payment API endpoints.
Step 1: expose narrow tools, not a broad backend
Instead of giving the model a payment provider secret or a general curl tool, expose two wrappers:
get_order(order_id)→ read-only, tenant-scopedcreate_refund(order_id, amount_cents, reason)→ write, tenant-scoped, confirmation required
The wrappers run server-side with service credentials the model never sees.
Step 2: attach machine-enforced metadata to each tool
Each tool definition needs policy metadata outside the prompt:
effect: read | write | externalallowed_resources: tenant_id=...max_amount_centsor similar business limitsrequires_confirmation: truefor side effectsidempotency_required: truefor writes
The model can see a description, but your runtime enforces the metadata.
Step 3: evaluate the proposed call before execution
User says: "Refund order 8472 for the duplicate charge."
The model proposes:
{"tool":"create_refund","arguments":{"order_id":"8472","amount_cents":1299,"reason":"duplicate charge"}}
Your policy gate checks:
- Is
create_refundallowed for this agent class? - Does order
8472belong to the current tenant? - Is this environment
prodand therefore confirmation-required? - Is
1299within the configured limit? - Has the exact action already been attempted with the same idempotency key?
Because this is a write, the gate returns PENDING_CONFIRMATION instead of executing.
Example response shape:
{"status":"PENDING_CONFIRMATION","action_id":"act_01JY...","summary":"Refund $12.99 for order 8472","risk":"medium"}
Step 4: confirmation happens outside the model
A human reviewer or a higher-trust service approves the action. The approval should bind to the exact arguments, not just the tool name. If the amount changes from 1299 to 129900, the old approval must not apply.
A good implementation stores a hash over:
- tool name
- normalized arguments
- tenant/environment
- requester identity
- expiry timestamp
Step 5: execute with a scoped credential
After approval, the wrapper calls your internal payments service using a token that can only create refunds for that tenant or merchant account. The wrapper should also set an idempotency key.
Realistic HTTP shape:
curl -i -X POST https://payments.internal/refunds \
-H 'Authorization: Bearer eyJ...scoped...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: refund-order-8472-1299-duplicate-charge' \
--data '{"order_id":"8472","amount_cents":1299,"reason":"duplicate charge","tenant_id":"acme"}'
Typical success output:
HTTP/1.1 201 Created
content-type: application/json
x-request-id: 7d9f2d6c
{"refund_id":"rf_2190","status":"submitted"}
Typical denial if the wrapper token is too broad or wrong tenant binding is missing:
HTTP/1.1 403 Forbidden
content-type: application/json
x-request-id: 0c12ab44
{"error":"scope_denied","message":"token not authorized for tenant acme"}
That 403 is good news: it means the payment service is enforcing least privilege too, not trusting the agent layer alone.
Step 6: return a constrained result to the model
Return only what the model needs to continue the conversation, for example:
{"refund_id":"rf_2190","status":"submitted","amount_cents":1299}
Do not pass raw provider tokens, full HTTP traces with secrets, or unrelated customer data back into the context window.
When to use it (and when not to)
If the agent can do anything beyond pure text generation, you should evaluate this seriously.
| Scenario | Recommendation |
|---|---|
| Agent can read internal docs only | Usually read-only scopes are enough; confirmation not needed unless docs are sensitive and cross-tenant |
| Agent can query production data | Use least privilege now; read-only DB roles, row-level restrictions, query allowlists |
| Agent can send email, create tickets, call webhooks, or post to chat | Require confirmation for external side effects, especially if messages leave your org |
| Agent can modify records, issue refunds, deploy code, or delete data | Require confirmation and narrow wrappers; avoid direct shell/admin API access |
| Internal prototype on fake data in an isolated environment | You can relax confirmation, but still avoid broad credentials so the prototype does not become production by accident |
| Single-user local tool on your laptop with no external access | You probably do not need a full approval workflow; you still want command allowlists if shell is exposed |
| Fully deterministic backend workflow with no model-chosen actions | You may not need agent-specific controls; standard service auth and change control may be enough |
You probably do not need a confirmation step for every read. Doing that turns the agent into a slow manual UI. Reserve confirmation for actions that change state, spend money, contact outsiders, or widen access.
Trade-offs
Least privilege and confirmation are worth it, but they are not free.
-
Benefit: lower blast radius if the model is wrong or prompt-injected.
Cost: more wrapper code, more policy logic, more test cases. -
Benefit: safer production access.
Cost: engineers must design resource-scoped credentials instead of reusing one admin token. -
Benefit: auditable approvals for refunds, deletes, deploys, and outbound messages.
Cost: added latency and user friction; some tasks stop feeling "instant". -
Benefit: easier incident containment.
Cost: operational burden around key rotation, policy rollout, and approval queue monitoring. -
Benefit: better compliance story.
Cost: lock-in risk if you depend on a vendor-specific policy engine or tool protocol. Prefer simple internal policy checks and standard auth where possible. -
Benefit: reduced accidental data leakage into prompts.
Cost: wrappers must sanitize outputs, which can hide details useful for debugging.
Two edge cases experienced teams ask about:
- What about multi-step attacks? Read-only access can still be dangerous if the agent can exfiltrate data through an external tool. Treat outbound HTTP, email, chat posting, and file upload as side effects requiring confirmation or strict destination allowlists.
- What about shell access? A shell is not one tool; it is thousands. If you must expose it, run a wrapper that accepts specific subcommands and fixed flags, not arbitrary strings.
In practice
Example 1: policy gate for tool execution
{
"tools": {
"get_order": {
"effect": "read",
"requires_confirmation": false,
"resource_scope": ["tenant_id"],
"allowed_args": {
"order_id": { "type": "string", "pattern": "^[0-9]{1,12}$" }
}
},
"create_refund": {
"effect": "write",
"requires_confirmation": true,
"resource_scope": ["tenant_id"],
"idempotency_required": true,
"limits": {
"amount_cents_max": 50000
},
"allowed_args": {
"order_id": { "type": "string", "pattern": "^[0-9]{1,12}$" },
"amount_cents": { "type": "integer", "minimum": 1, "maximum": 50000 },
"reason": { "type": "string", "maxLength": 120 }
}
}
}
}
This is the minimum useful shape for a policy file: effect type, confirmation requirement, resource scope, and argument validation. The gotcha is treating schema validation as sufficient security; it is not. You still need server-side authorization against the actual resource owner and environment.
Example 2: Postgres role split for read-only agent access
⚠️ Running
ALTER DEFAULT PRIVILEGESor broadGRANTstatements against the wrong database can expose production data. Runpsql "$DATABASE_URL" -c 'select current_database(), current_user'first and verify the target.
CREATE ROLE agent_readonly LOGIN PASSWORD 'replace-with-secret';
REVOKE ALL ON DATABASE appdb FROM agent_readonly;
GRANT CONNECT ON DATABASE appdb TO agent_readonly;
GRANT USAGE ON SCHEMA public TO agent_readonly;
GRANT SELECT ON TABLE public.orders, public.customers TO agent_readonly;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO agent_readonly;
This gives the agent a role that can read specific tables and nothing else. The gotcha is the ALTER DEFAULT PRIVILEGES line: it only affects future tables created by the current role, so you still need explicit GRANT SELECT for existing tables and may need to repeat it for migrations run by a different owner role.
You can verify the role behaves as intended:
psql "$AGENT_DATABASE_URL" -c 'select count(*) from public.orders;'
psql "$AGENT_DATABASE_URL" -c "delete from public.orders where id = 8472;"
Expected failure shape for the delete:
ERROR: permission denied for table orders
SQLSTATE: 42501
Example 3: shell wrapper with command allowlist
#!/usr/bin/env bash
set -euo pipefail
case "$1" in
"git-status") exec /usr/bin/git -C /srv/repo status --short ;;
"git-diff") exec /usr/bin/git -C /srv/repo diff --stat --cached ;;
*) echo "denied: unsupported command" >&2; exit 64 ;;
esac
This wrapper exposes two read-only repo inspection commands and rejects everything else with exit code 64. The gotcha is argument smuggling: if you later add passthrough arguments like "$@", you can accidentally reintroduce arbitrary command execution or dangerous flags.
Further reading
- OWASP Top 10 for Large Language Model Applications
- NIST SP 800-53, Access Control family
- PostgreSQL Documentation, "GRANT" and "ALTER DEFAULT PRIVILEGES"
- OAuth 2.0 Bearer Token Usage (RFC 6750)
- Google Zanzibar paper
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI