Agent security and permissions
Prompt injection from tool output, least-privilege credentials, sandboxing, egress control, and auditing every side effect.
The threat model
An agent combines three properties that are individually manageable and dangerous together: it reads untrusted content, it holds credentials, and it can take actions. Any content it reads is a candidate instruction, and the model cannot reliably distinguish data from command.
| Threat | How it arrives | Control that actually works |
|---|---|---|
| Indirect prompt injection | A web page, email, ticket or PDF the agent reads | Treat output as data; authorise outside the model; allow-list actions |
| Argument injection | Model-generated text used in SQL, shell or a path | Parameterised queries, no shell, path allow-list |
| Confused deputy | The agent acts for a user against resources it should not reach | Per-user scoped tokens, resource-level authorisation on every call |
| Credential exfiltration | Secrets in context, or a tool that can call arbitrary URLs | Secrets only inside tools; egress allow-list |
| Over-broad action | A destructive call that was permitted and plausible | Approval gates, dry-run, idempotency keys, narrow scopes |
| Supply chain | A malicious tool, plugin or MCP server | Pin and review; run with least privilege; no ambient credentials |
| Data exfiltration through output | The answer itself carries data out | Output filtering, tenant isolation, logging and review |
The design principle that covers most of this: the model decides intent, and your code decides authority. If the model's output is the last thing standing between a request and an effect, you do not have a security boundary.
Least privilege in practice
def scoped_client(user_id: str, run_id: str):
"""A token for this user, this run, this scope - never an admin key."""
token = auth.exchange(
subject=user_id,
audience="billing-api",
scopes=["orders:read", "refunds:request"], # not refunds:execute
ttl=timedelta(minutes=15),
run_id=run_id,
)
return BillingClient(base_url="https://billing.internal", token=token)
def request_refund(user_id, order_id, amount):
client = scoped_client(user_id, current_run().run_id)
return client.post("/refunds", json={"order_id": order_id, "amount": amount})- Never run every request as one service account. Use per-user tokens so the downstream API applies the same rules it applies to a human.
- Short-lived credentials only. An agent that can run for hours must not hold a token that outlives the task.
- No tool should accept a raw URL, shell command, SQL string or filesystem path from the model. Give it named operations instead.
- Nothing that reaches the model's context may be used for authorisation — not a role in a fetched document, not a claim in an email.
- Read replicas and read-only keys for read tools. Write credentials exist only in the code path that also checks approval.
# sandbox for a tool that runs untrusted code or processes untrusted files
apiVersion: v1
kind: Pod
metadata:
name: tool-runner
spec:
automountServiceAccountToken: false
containers:
- name: runner
image: ghcr.io/example/tool-runner:1.2.0
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi- Sandbox by default: no ambient cloud credentials, read-only filesystem, no network unless the tool needs it.
- Control egress with an allow-list. An agent that can reach any host can exfiltrate anything it has read, using the tool as the transport.
- Bound resource use. A tool that processes a hostile 500 MB file should hit a memory limit, not the node.
- Give each tool its own identity and its own limits, so a compromise is contained to one capability.
Auditing every side effect
def audited_write(action: str, target: str, payload: dict, fn):
"""Every state change goes through here. Append-only, no exceptions."""
record = {
"action": action,
"target": target,
"payload_hash": sha(json.dumps(payload, sort_keys=True)),
"actor": {"user": current_user(), "agent": AGENT_ID, "run_id": current_run().run_id},
"authorisation": current_run().authorisation, # approval id, or the scope used
"idempotency_key": current_run().idempotency_key_for(action, target),
"timestamp": utcnow(),
"result": None,
}
try:
result = fn(**payload)
record["result"] = {"ok": True, "ref": result.get("id")}
return result
except Exception as e:
record["result"] = {"ok": False, "error": str(e)}
raise
finally:
audit.append(record) # written even when the call fails- Log intent as well as effect: which run, which user, which authorisation the action rested on.
- Make writes idempotent with a key derived from the action and target, so a retry after a timeout cannot double-charge.
- Store audit records append-only, in storage the agent cannot write to directly and cannot delete from.
- Reconcile after an incident: replay the audit log against the downstream system to find every effect the run produced.
- Alert on writes that lack an approval reference where one was required. That alert is your last line of defence.
⚠️
Anything the agent reads is an instruction candidate. Assume a poisoned document, page or ticket is actively trying to make your agent act, and never let the model's own judgement be the final authorisation for an irreversible action.
FAQ
Can I prompt my way out of prompt injection?
No. You can reduce the frequency with clear delimiting and explicit instructions that fetched content is data, but the fix is architectural: allow-list the actions, scope the credentials per user, gate irreversible ones, and verify effects after the fact.
Should the agent have its own user account?
Give it its own identity for auditing, but act on behalf of the requesting user with a delegated, scoped token. One shared powerful account for all users destroys both the audit trail and the permission model.
Related
Human-in-the-loop and approval gates Model Context Protocol in practice
Last refreshed 2026-09-18.