Module 2 · 6 min read
Tool design, least privilege and capability tokens
Why the privilege an agent holds lives in its tools rather than in its instructions, and how scoped, short-lived credentials bound the damage when the agent is steered.
An agent exposes a single tool: a function that takes an arbitrary SQL string and runs it against a shared service account with read and write access to the whole database. Injection has already been demonstrated against it. There are four proposals on the table. Add a system prompt rule forbidding write statements. Wrap the tool in a classifier that blocks queries that look malicious. Log all queries and alert on writes. Or replace the tool with narrow parameterised tools, each holding a credential scoped to only the tables and verbs it needs. Only the last one changes what an attacker can do.
The system prompt rule fails for a structural reason worth internalising: the control lives inside the very channel the attacker controls. Injected text and your instructions are both just tokens in the context window, and there is no privileged tier that reliably wins. You are asking the attacker's medium to enforce your policy. The classifier is a better idea and still weak: it is a pattern matcher facing an adversary who can generate unbounded semantically equivalent variants, and it has no access to intent, so it must either block legitimate work or let attacks through. Logging and alerting is detection, not prevention: it tells you which tables were exfiltrated, after they were.
Capability-shaped tools
- One tool per capability, with typed and constrained parameters
- A distinct credential per tool, scoped to the tables and verbs it needs
- Reads separated from writes, reversible separated from irreversible
- Result sizes bounded, so a read tool is not an exfiltration tool
- A fully hijacked model can still only do what the task requires
One broad tool plus guidance
- A free-form string passed through to the backend
- One shared service account with broad rights
- Policy expressed as instructions in the prompt the attacker also writes to
- A classifier guessing intent from surface patterns
- Logging that tells you afterwards which tables left
What a well-designed tool looks like
Granularity should follow capability, not developer convenience. One tool that does everything is convenient to write and impossible to constrain. Prefer typed, constrained parameters over free-form strings, because a free-form string passes the attacker's choice straight through to the backend. Where a tool must accept an identifier, constrain it to an enum or a pattern and resolve it server-side against the caller's entitlements rather than trusting the value. Bound result sizes, because a read tool that can return a million rows is an exfiltration tool. And separate read from write, and reversible from irreversible, so that the dangerous verbs can carry extra conditions.
# Before: one tool, one shared credential, unlimited reach
def run_sql(query: str) -> list:
return db_readwrite.execute(query)
# After: capability-shaped tools, each with its own scoped credential
def lookup_order(order_id: str) -> dict:
# credential: SELECT on orders, order_lines only
assert ORDER_ID_PATTERN.fullmatch(order_id)
return db_orders_ro.fetch_one(
"SELECT id, status, placed_at FROM orders WHERE id = ? AND tenant = ?",
(order_id, principal.tenant_id),
)
def issue_refund(order_id: str, amount_minor: int, reason: str) -> dict:
# credential: INSERT on refunds only; no UPDATE, no DELETE, no other table
# irreversible and financial: requires explicit human confirmation
require_human_approval(
summary=render_refund_preview(order_id, amount_minor), # real effect, not model text
)
return refunds_rw.create(order_id, amount_minor, reason, actor=principal.id)Note the detail in the confirmation step. When a human approves an irreversible action, the interface must show the actual effect computed from the validated arguments, not a natural language summary the model produced. Otherwise the model describes a small refund while the arguments encode a large one, and the human has approved the description rather than the action. This is the same failure as trusting model output anywhere else, only with a person interposed to absorb the blame.
Capability tokens: bounding scope and lifetime together
The next step is to stop the agent from holding standing privilege at all. Instead of a long-lived service account, the platform mints a token per tool invocation: authorised by policy, scoped to the specific capability and resource for this step, valid for seconds or minutes, and bound to the invocation so it cannot be replayed elsewhere. The agent never sees the token; the platform attaches it when it executes the call.
The security argument is that this compresses the blast radius in two dimensions at once. An injected or hijacked agent holds only the scope of the current step, so steering it does not unlock anything the step did not already justify. And theft is bounded by a short expiry, so a token captured from a log, a crash dump or a compromised sandbox is worthless almost immediately. Better audit trails are a genuine secondary benefit, since each token maps cleanly to one authorised action, but that is detective rather than preventive and is not the primary argument. Two things capability tokens do not do: they do not remove the need for schema validation on tool arguments, which is a separate control at a separate boundary, and they must never let the model choose its own scopes, which would hand the authorisation decision to the component you are trying to contain.
- The model proposes an actionIt emits a tool name and arguments. This is a request, not an authorisation, and nothing about it is trusted yet.
- The platform validates the argumentsStrict schema, unknown fields rejected, values range-checked and canonicalised. A malformed or out-of-policy request stops here without any credential being created.
- Policy decides, not the modelThe platform evaluates whether this principal, in this session, at this step, may perform this action on this resource. The model never selects its own scopes.
- Mint a narrow, short-lived tokenScope it to one capability and one resource, set an absolute expiry a few seconds or minutes out, bind it to this invocation, and give it a unique identifier so it can be single use. Include the acting user so downstream services can authorise the delegation.
- Execute with the token attached outside the context windowThe platform makes the call and supplies the credential. The token never appears in the prompt, the completion or anything the model can read back.
- Record the token and the outcomeOne token maps to one authorised action, which gives a clean audit trail. Useful, but treat it as detection: the preventive value came from the scope and the expiry.
{
"iss": "agent-platform",
"sub": "agent-run/9f21c4",
"act": { "sub": "user/48213" },
"aud": "refunds-service",
"scope": ["refunds:create"],
"resource": "order/AB-4417",
"max_amount_minor": 5000,
"iat": 1785312000,
"exp": 1785312060,
"jti": "3f9a1c7e-5b02-4d18-9a6c-71e0c4f2ab93"
}Check yourself
An agent runs under a service identity with broad administrative rights, acting on behalf of a user whose own account is read-only. The downstream service authorises each call against the agent's service identity. What is the defect?
This is a confused deputy: the agent is more privileged than the person it acts for, so anyone who steers the agent has escalated privilege without stealing a credential. The acting user must be carried through the call and the downstream service must authorise against the intersection of the agent's and the user's rights.