Part 3 of 5 · 10 min read
Credentials: standing keys against task grants
Covers: Credentials: standing keys against task grants; Approval is a control only if people can use it and more.
Credentials: standing keys against task grants
The standing grant in C1 and C2 is what most systems do: one broad credential for the whole session. It holds five permissions, including payments:refund and tickets:close, and it is valid whenever the session is.
The task grant in C3 is minted by a small broker for one purpose: "view" gets tickets:read; "note" adds tickets:append; "refund" gets payments:refund plus a cap and one allowed change. It also expires:
t=0: allowed (ok)
t=299: allowed (ok)
t=300: denied (grant expired)
t=900: denied (grant expired)
Why this is better, in plain terms: the question to the broker ("what does this task need?") is asked before the model reads any untrusted text. The grant is then fixed. Text that arrives later, however persuasive, cannot widen it.
What the example does not do
These grants are plain objects in one process. A real system uses signed tokens that a separate service issues and checks, with an audience (which service the token is for) so that a token for one system cannot be replayed at another. Module 5 builds that, with MCP's authorisation model. The point here is the shape: narrow, short-lived, issued per task, checked in code you control. Never let the model or the user's text decide what a grant contains.
Approval is a control only if people can use it
The gateway can ask a person before close_ticket and refund. Whether that helps depends on how often it asks and what the person sees. Prompts per 100 tasks, for an assumed mix (60 views, 25 notes, 10 closes, 5 refunds; I made the mix up, so use your own):
| Policy | Prompts per 100 tasks |
|---|---|
| approve nothing | 0 |
| approve every call | 100 |
| approve every change (notes, closes, refunds) | 40 |
| approve closes and refunds only (the example) | 15 |
A person shown 100 prompts a day will click "yes" on the 100th without reading it. That person is the rubber stamp of setup C2. The design aim is to ask rarely, and only when it matters:
- Do not ask for what code can refuse. If a cap or a scope would say no, say no, and spare the person.
- Do not ask for what is harmless or undoable. Reads and appended notes need no prompt. (Appending is undoable by an operator; "edit any note" is not, and would be a different tool.)
- Ask for the irreversible or the costly: closing, refunding, sending, deleting.
- Show what will happen, in words, with the arguments: "Close ticket 5 ('Duplicate charge') for Ben Okoye". Not "the agent wants to run close_ticket. Approve?"
- Say no safely. A rejection comes back as text: the person did not approve this action; do not retry it; tell them what you wanted to do and why. The model must not loop on a "no".
Which control is doing the work?
Start from C2 (standing grant, a person who approves everything) and switch off one control at a time, then run the same eight requests:
| Control switched off | What gets through | What it tells you |
|---|---|---|
| none | close all: 2 rows |
the baseline: the quota of 2 changes limits it |
| scope | + reading the card endings (get_payment) |
the permission is what kept payment data out of a support task |
| row check | + a note written on another customer's ticket; 3 rows | without it the agent could act on anyone's data |
| quota | close all rises to 3 rows |
the quota was the limit on repeated changes |
| request validation (the cap) | a refund of 5000 | the cap is the only thing between a "yes" click and the money |
| approval | nothing changes | the person said yes to everything, so they added no safety |
Three lessons:
- Each control stopped something the others did not. There is no single magic one. That is why the gateway has ten gates and not one.
- The person was the weakest control here (and in a real team, always check how much it is worth). The hard limits (cap, scope, row, quota) did the work.
- This is a toy experiment with a scripted attacker. It shows what each control is for, and not how much real protection each gives.
A refusal should help the model do the next right thing
When the gateway says no, the model reads the text and decides what to do. A good refusal says what was wrong and what to do; it does not leak internals. From the example:
| Call | Refusal text |
|---|---|
get_ticket(6) for another customer |
Error: ticket 6 is not part of this conversation's customer. |
get_payment(2) without the permission |
Error: not permitted. get_payment needs the 'payments:read' permission, which this task was not given. |
refund(2, 5000) |
Error: refunds above 50.00 need a human agent. Offer the customer a hand-off. |
| a person says no | Error: the person did not approve this action. Do not retry it; tell them what you wanted to do and why. |
delete_everything() |
Error: unknown tool 'delete_everything'. Available: get_ticket, list_open_tickets, add_note, ... |
Compare with a raw database error or a stack trace, which tells a model nothing useful and tells an attacker how your system is built. (Module 6 covers the same for an MCP server: tool errors should tell the model what to do next.)
The cost of narrow tools, and how to pay it
C did 6 of 7 honest tasks. The seventh ("open tickets on each plan") needs a report, and there is no tool for it. You have four honest options:
- Add a narrow tool for that report (
open_tickets_by_plan()), run through the same gateway. This is the normal answer; tool lists should grow by need. - Expose a prepared view with only safe columns, through a tool with a fixed query. (Exercise Medium 3.)
- Hand off to a person or a different, more privileged workflow that has its own approvals. The support agent says "I can't do that; here is who can".
- Offer a broad tool, behind a strong gate, for a different, trusted audience (for example a data analyst working through their own read-only replica with their own credentials). Not for the agent that reads customers' tickets.
Tip: widen on evidence
Start narrow. When a task fails because there is no tool, write the tool. Keep a log of "no tool for this" results; it is your backlog, and it is a better design process than guessing in advance what the model will need.
Lint the tool list
Many of these problems can be found by reading the tool list, before any run. lint.py checks a list of tools for smells:
| Rule | Fires when |
|---|---|
| free-form input | a parameter is named sql, code, command, script and so on |
| broad description | the description promises "any" or "arbitrary" power |
| no scope | the tool needs no permission |
| money without cap / approval | an effect that moves money has no enforced limit or no approval |
| write without approval | a state-changing tool has no approval step |
| no row scope | a tool acts on an id without checking the row belongs to the caller |
Results: the SQL tool trips three rules (free-form-input, broad-description, and for the read-write version write-without-approval); the narrow set trips none. A tool list such as transfer(account_id, amount) with no scope, no cap and no approval, run_shell(command), delete_ticket(ticket_id) and get_user(user_id) trips ten findings, six of them rated high.
A linter is not a proof. It finds the shape of a problem; it cannot tell you that a tool's own code is wrong. Use it as the first review, and in continuous integration so that a new tool cannot arrive without a scope and a row check.
A checklist
- Narrow tools: one job each, no free-form code or query fields, no argument the model need not choose.
- Read-only by default. A write is a separate tool with its own permission.
- Reads are not free: limit what is returned (columns, rows), not only whether writes are possible.
- Scopes: every tool names the permission it needs; a grant lists the permissions it has.
- Row scope comes from the grant, set by your code, not from model-chosen arguments.
- Limits in code: caps, quotas, rate limits. Check them before asking a person.
- Grants per task, short-lived, with an expiry and an audience.
- Approval for the irreversible or costly, shown in words, asked rarely, and a "no" returned as guidance.
- Refusals say what to do next and leak nothing.
- Log every call, allowed or denied, with the reason.
- Lint the tool list in CI and test the controls with attack tasks, not only happy paths.
Levels 9 and 10 return to measuring and attacking all of this: a poisoned ticket is prompt injection, and the combination of private data, untrusted text and a way to send data out is the case that most needs these controls.
Interview angle
"How would you secure a server that exposes internal data to an agent?" Start with least privilege and make it concrete in layers: narrow tools instead of a query tool (and say why "read-only SQL" still leaks), scopes per tool, row-level scope taken from the credential, not from arguments, caps and quotas enforced in code, short-lived credentials issued per task with an expiry and an audience, approval only for irreversible or costly actions (and say why a rubber-stamp approver is not a control), useful refusals, and an audit log of every call. Say you assume the model will be fooled by text it reads, so the controls sit outside the model. Then add what you would test: a set of attack tasks (drop, exfiltrate, cross-customer, over-limit) and a linter for new tools. Weak answers say "I would tell the model not to do that", "I would use a read-only user" and stop, or "I would add human approval" for everything.
Follow-ups to expect: "How does MCP change this?" (the protocol standardises how tools are described and called; it does not decide what a tool may do, so these controls apply to every MCP server; modules 2 and 5) and "What is prompt injection and why does it matter here?" (instructions hidden in data the model reads; Level 10).
Check yourself
A tool lets the model run any SQL query, and a poisoned support ticket asks it to dump the customer table. Which control stops that, and why is "tell the model not to" not it? (A narrow tool with a read-only role and a row limit stops it in code. An instruction is a request the model may follow anyway.)
For the curious Three small fixes found in the example code
The argument check treated True as the number 1, so a boolean passed a numeric field; it now refuses booleans where a number is expected. Minting a credential for an unknown task did not fail clearly; it now raises a ValueError that lists the known tasks. A tool that crashed took the gateway call down with it; the gateway now catches the crash, logs it, and returns an error the model can act on.
What you will use daily at work
| Practice | What it looks like |
|---|---|
| One job per tool | get_order and refund_order, not run_sql. |
| Task-scoped credentials | A short-lived grant for one task, not a standing key. |
| A gateway in front of tools | Argument checks, an allow list, a log, and approval for the risky ones. |
Draft: confirm how your team issues credentials to agents and who approves risky tool calls.