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.

ThreatHow it arrivesControl that actually works
Indirect prompt injectionA web page, email, ticket or PDF the agent readsTreat output as data; authorise outside the model; allow-list actions
Argument injectionModel-generated text used in SQL, shell or a pathParameterised queries, no shell, path allow-list
Confused deputyThe agent acts for a user against resources it should not reachPer-user scoped tokens, resource-level authorisation on every call
Credential exfiltrationSecrets in context, or a tool that can call arbitrary URLsSecrets only inside tools; egress allow-list
Over-broad actionA destructive call that was permitted and plausibleApproval gates, dry-run, idempotency keys, narrow scopes
Supply chainA malicious tool, plugin or MCP serverPin and review; run with least privilege; no ambient credentials
Data exfiltration through outputThe answer itself carries data outOutput 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.

Human-in-the-loop and approval gates Model Context Protocol in practice

Last refreshed 2026-09-18.