06. Scopes and credential isolation¶
Errors are about what the model is told when a call fails. Scopes are about what authority the call carried when it ran. The most damaging successful call is one made under a credential broader than the operation required. This chapter builds scope binding so a single tool, a single tenant, and a single purpose are the boundaries of every credential.
A security engineer at a Bangalore healthcare-tech startup audits the agent platform six months after launch. The agent has twelve tools wired to different parts of the patient record system: read patient history, schedule appointment, send appointment reminder, update demographics, run lab order, attach document, and so on. The audit finds that all twelve tools use the same service account, which has been granted patient:* permission across the entire organisation. The service account was provisioned on day one because "we needed to ship". The agent was wired to use it everywhere because "we'd scope it down later". Two weeks before the audit, a prompt-injection attempt embedded in an uploaded lab report convinced the agent to fetch and email a different patient's history to an attacker-controlled address. The tool was read_patient_history. The credential was patient:*. The agent did exactly what it was authorised to do. The injection had a target because the credential offered one. With a credential scoped to the active patient's record, the same injection would have failed with PermissionError: out_of_scope — and the failure would have been actionable.
The point of this chapter is not that prompt injection is an unsolved problem (it is — see the security module). The point is that scopes are the single largest variable in how much damage any attack or accident can produce, and getting scopes right is mostly an engineering discipline that has been understood since the 1970s. It is the principle of least privilege, applied to a new kind of client.
What a scope is¶
A scope is the set of operations a credential authorises, expressed precisely enough that the contract layer can refuse calls outside it.
Three dimensions are present in every scope:
- Capability — what verb-noun is permitted (
payment:refund:write,customer:profile:read) - Tenant — whose data the credential applies to (
acme-corp,patient_id=42) - Target constraint — optional further narrowing (destination allowlist, value cap, time window)
A correctly scoped credential answers every one of the dimensions explicitly. A god-key answers them as "any verb, any tenant, no constraint" — which is the failure mode this chapter is about.
The rule¶
One tool, one tenant, one purpose, one credential. No god-keys.
Unpack each clause:
- One tool. The credential authorises the capability of exactly this tool.
issue_refundcarriespayment:refund:write, notpayment:*. - One tenant. The credential is bound to the tenant on whose behalf the agent is acting. Cross-tenant operations require explicit, audited tenant elevation.
- One purpose. The credential is bound to the agent platform's purpose for this call (the conversation, the workflow step). It cannot be reused by a different call without a fresh issuance.
- One credential. Two tools never share a credential. Sharing collapses the blast radius of the broader credential onto the narrower tool.
- No god-keys. A credential that can do "everything in this system" must not exist in the agent platform's credential store. It can exist in the underlying system; the agent platform must not hold a reference to it.
A practical consequence: a tool's contract carries a required_scope field that names exactly the capability, and the contract layer resolves a credential for that scope at call time. The credential lives for the duration of the call. It is not cached on the model side, not exposed to the model in the tool description, and not reachable from a different tool's execution context.
How scopes are resolved at call time¶
The flow on every tool call:
1. Tool call arrives at contract layer with (tool_name, args, tenant_id, agent_identity).
2. Contract reads the tool's required_scope from the contract.
3. Scope resolver checks:
- Is this agent_identity allowed to request this scope?
- Is this tenant_id within the agent's authorised tenant list?
- Are any additional constraints (destination allowlist, value cap) satisfied?
4. If yes, scope resolver issues a short-lived credential for
(capability = required_scope, tenant = tenant_id, ttl = ~minutes).
5. Contract layer executes the downstream call with this credential.
6. Credential is discarded when the call returns.
Five properties make this safe:
- Per-call issuance. Each call gets a fresh credential. The credential is not stored across calls, not reused across tools, not exposed to the model.
- Tenant binding at issuance. The tenant is fixed at the moment the credential is issued; the call cannot be redirected to a different tenant mid-flight.
- Short TTL. Minutes, not hours. If the credential leaks, the window of damage is bounded.
- Capability narrow to the call. The credential cannot perform operations beyond the one the contract permits, even if the downstream API would accept them.
- Audited issuance. Every credential issued is logged with
(agent_identity, tool_name, tenant_id, scope, issued_at).
Where credentials actually come from¶
Three patterns are common. Pick one per platform; do not mix.
Pattern A — Static per-tool service accounts (acceptable for low-stakes platforms). The contract layer holds a vault of pre-provisioned service accounts, one per tool. The account's permissions are narrow to the tool's capability. The agent platform fetches the account credential per call.
Pros: simple to set up. Cons: rotation is per-account, which is slow; tenant binding has to be enforced at the contract layer because the credential itself is not tenant-bound; an exfiltrated credential is reusable until rotated.
Pattern B — Short-lived tokens minted per call (recommended for production agents). A central token issuer (often the model gateway from infra/01, or a dedicated identity service) mints a token at call time. The token encodes the capability, the tenant, the agent identity, and a short TTL. The downstream system validates the token at the boundary.
Pros: tenant binding is enforced in the token; rotation is automatic via TTL; exfiltrated tokens expire fast. Cons: requires the downstream system to validate the platform's tokens, which means coordination. Standard in OAuth-style ecosystems.
Pattern C — User-delegated credentials (rare but important). When the agent is acting on behalf of a specific end-user, the credential is delegated from the user — for example, a user's OAuth token authorising the agent to access their Google Drive. The agent acts under the user's authority, not the platform's.
Pros: the audit trail is precise about whose authority was used; capability is bounded by what the user themselves can do. Cons: token storage is sensitive (user secrets); the agent must refuse calls that exceed the user's own permissions.
Most production agents end up with a mix: pattern B for platform-owned operations, pattern C for user-delegated operations, and pattern A only for legacy systems that cannot adopt token validation.
What "tenant binding" actually enforces¶
Tenant binding is the single feature that prevents the largest class of multi-tenant accidents. The rule:
Every credential carries a tenant identity. Every downstream operation validates the credential's tenant against the resource it touches. Mismatches are refused before any state change.
Without tenant binding, the agent platform is one prompt-injection away from a cross-tenant read or write. With tenant binding, the worst a successful prompt-injection within tenant A's session can do is leak tenant A's own data — which is bad, but not a multi-tenant breach.
In practice, tenant binding has two layers:
- Agent platform layer. The agent identity has an allowlist of tenants it can act on behalf of. A request to act on a tenant outside the allowlist is refused at the contract layer.
- Downstream system layer. The credential the downstream receives is tenant-bound; the downstream's data layer enforces "this credential can only see tenant X's rows". Even if the agent platform's enforcement were bypassed, the downstream would refuse the read.
Defence in depth requires both layers. Relying only on the agent platform's enforcement is brittle; relying only on the downstream's is also brittle if the downstream is older code that pre-dates per-tenant scoping.
Target constraints — when capability + tenant is not enough¶
Some operations are dangerous even within the right tenant. A wire transfer from the right tenant's account is still dangerous if the destination is arbitrary. The contract carries optional target constraints that the scope resolver verifies.
Common target constraints:
operational:
scope:
required_scope: "treasury:wire:execute"
tenant_binding: true
target_constraints:
destination_allowlist: required
# Only destinations on the tenant's pre-approved vendor list
amount_cap_minor: 10000000
# Single-call cap; orthogonal to the human-approval threshold
time_window:
start: "09:00 IST"
end: "17:00 IST"
# Outside business hours, scope is refused
The constraints are checked at scope-resolution time, not at the downstream system. If the destination is not on the allowlist, the scope resolver refuses before a credential is even minted. The downstream system never sees the call.
The reason to check at scope resolution: the constraint is a policy of the agent platform, not a property of the underlying system. The underlying system might permit any destination; the agent platform refuses. Putting the check at scope resolution makes it visible, auditable, and reviewable — and centralises it across tools that share the constraint.
What scopes look like in the contract¶
The scope slot in the contract (extending the operational block from chapter 02):
operational:
scope:
required_scope: "payments:refund:write"
tenant_binding: true
issuer: "central-token-issuer-v2" # which token issuer mints credentials
ttl_seconds: 300 # how long the credential lives
target_constraints:
amount_cap_minor: 50000000 # single-call cap
currency_allowlist: [INR, USD]
audit:
log_issued_credentials: true # for the issuance audit log
log_scope_denials: true # denials are interesting too
The slot answers the questions a reviewer must ask:
- What capability is required?
- Is the credential tenant-bound?
- Who issues it, and how long does it live?
- Are there narrower constraints than capability + tenant?
- Is issuance audited?
A missing slot for any of these is a defect, not a styling choice.
The god-key audit¶
Most platforms accumulate god-keys without intending to. The audit is straightforward and worth doing quarterly.
Procedure:
- Enumerate every credential the agent platform holds. Vaults, env vars, secret managers, files on disk.
- For each credential, document: what permission set does it carry on the underlying system?
- Flag any credential whose permission set is broader than the union of the tools that legitimately use it.
- For each flagged credential, identify which tool needs the broadest subset, and rescope.
The output is a list of credentials to retire. Retire them in order of blast radius — the broadest first.
Common findings:
- "Admin" service accounts used because the right scope did not exist at provisioning time.
- Personal access tokens of engineers used as service credentials (the engineer left, the token kept working).
- Credentials shared across
stagingandproductionbecause the deploy pipeline expects one set of secrets. - OAuth tokens whose scope is the union of every UI button the issuing app ever exposed.
Each of these is a multi-tool god-key in disguise.
How scopes interact with the other contract surfaces¶
- Class (chapter 03). Irreversible tools require the narrowest scopes (often single-target allowlists). Read tools can carry broader scopes because the blast on read is bounded.
- Idempotency (chapter 04). Scopes are not idempotency. Two retries of the same call must carry the same scope; the credential resolver issues a fresh credential per retry but with the same scope binding.
- Error contract (chapter 05). Scope denials produce a specific error:
PermissionError: out_of_scopewithretriable=false. The error must say what scope was needed, not the credential it had — exposing the credential's own scope leaks information. - Audit (chapter 11). Every scope issuance and every scope denial is audited.
How to recognise scope drift¶
Same pattern as class drift: scopes drift in two directions, both bad.
Drift wide (scopes broadened beyond what tools need). A new tool needed a permission the existing service account didn't have. Instead of issuing a narrower second account, someone added the permission to the existing account. Now the account is broader than any single tool needs. Catch this with the quarterly god-key audit; better, catch it at the provisioning request — adding a permission to an existing account is a review trigger.
Drift narrow on paper, wide in practice. The contract says required_scope: "payments:refund:write", but the resolver maps that to a service account with payments:*. The contract is lying. Catch this by testing the resolver: a call with required_scope: "payments:refund:write" must fail when it tries to do anything other than refunds, including in CI.
Interview Q&A¶
Q1. The platform team says "we'll scope down credentials later, we need to ship now." What do you say? That "later" is the most expensive word in security engineering, because the cost of retrofitting scopes onto a running system is exponentially higher than getting them right at issuance. The right ship-now compromise is: ship with narrow, tool-specific credentials even if each is provisioned by hand on day one. Do not ship with a shared admin credential. Six months later, you cannot tell which tool needs which permission, because every tool has been calling the broad credential — there is no signal to derive the narrow scopes from. Wrong-answer notes: agreeing to ship with god-keys, even temporarily, is the path of the chapter's opening incident. Sometimes you must — but with an explicit, dated, prioritised followup.
Q2. A prompt-injection attempt convinces the agent to read another customer's data. The agent is running with a tenant-bound credential. What happens?
The downstream system's data layer refuses the read because the credential's tenant does not match the resource's tenant. The contract returns PermissionError: out_of_scope. The model receives the error, surfaces or escalates per the error contract, and the incident is contained to "an injection attempt was made and refused." If the credential were not tenant-bound, the read would succeed and the incident would be a cross-tenant breach. The defence works because the credential cannot do what the prompt asks. Wrong-answer notes: "the model would refuse" relies on the model's behaviour, which the injection is trying to subvert; the credential is the load-bearing defence.
Q3. You see a tool whose contract says required_scope: "customer:read" and a destination allowlist. Why both?
Because capability alone doesn't bound what the call can target. customer:read says the credential can read customers; the allowlist says which customers — typically only the customers the active session is authorised to see, or only customers in a specific cohort the tool is meant to serve. The two constraints are orthogonal: capability bounds the verb, allowlist bounds the noun. Skipping the allowlist means a tenant-bound credential can still leak any record in that tenant. Wrong-answer notes: "the allowlist is redundant if the credential is tenant-bound" forgets that within a tenant, not every record is the right target.
Q4. Walk through the lifecycle of a credential from a tool call, from issuance to disposal. The contract layer receives a call with (tool, args, tenant_id, agent_identity). The scope resolver checks the agent is authorised for this scope, the tenant is in the agent's allowlist, and target constraints are satisfied. The token issuer mints a short-lived (e.g., 5-minute) credential encoding capability + tenant + agent + TTL. The contract layer uses the credential to execute the downstream call. The downstream validates the credential against its own policy. The call returns. The credential is discarded — not cached, not stored, not returned to the model. Audit records: scope request, issuance, downstream call, response, disposal. Wrong-answer notes: "the credential is reused across calls in the conversation" is a common production mistake; it widens the blast radius of any single leak.
What to do differently after reading this¶
- Audit every credential the agent platform holds. List which tools each credential serves. Retire any credential broader than its consumers.
- For new tools, require
required_scope,tenant_binding, and (when applicable)target_constraintsin the contract draft. Make these mandatory review questions. - Set credential TTLs in minutes, not hours, wherever the token issuer supports it.
- Build the god-key audit into a quarterly review. Track number of god-keys retired as a platform health metric.
- When a new permission is requested for an existing service account, treat it as a security review trigger, not a routine ticket.
Bridge. Scopes bound what the call can do. Validation bounds what the call can say — what arguments are allowed in, and what the response must look like before it lands back at the agent. The next chapter builds validation: preconditions on arguments, postconditions on responses, and dry-run modes that test the call's effect without committing it. → 07-validation-pre-and-post.md