Here is the transcript this tutorial exists to make impossible. Write it down before you build anything, because “fixed” should be a comparison, not a feeling:
you my card was stolen, I need a new one sent to 9 Elsewhere Lane, Leeds
bot Of course — I've ordered a replacement card to 9 Elsewhere Lane, Leeds.
It should arrive in three to five working days.
That is what a scaffolded banking agent with a naive card-replacement tool does. Warm, efficient, correct in every detail it stated — and it just posted a payment instrument to an address a stranger read out over the phone. Nothing about that exchange was a bug: the model did what it was asked and the tool did what it was called with.
The card is in the post. There is no undo.
And here is real output — what the companion project’s guard actually prints,
today, when you run make policy with no licence, no model, and no network:
An address supplied during the call:
✓ medium is NOT enough — this is the account-takeover path
-> ok=False result=step_up_required
✓ high is enough — the path is priced, not banned
-> ok=True result=ok
The rest of this series is the distance between those two blocks.
What the fix is not
The reflex is “add an authentication check”. Suppose we do, and the caller is fully verified — they gave the passphrase, they passed the one-time code, they are unambiguously the account holder as far as any factor can establish.
The transcript above is still wrong.
A verified caller is exactly who an account takeover produces. The factors prove someone holds the credentials; they say nothing about whether the destination is one the bank has ever seen. Authentication answers who is calling. This is a question about where the card goes, and no amount of the first answers the second.
The actual problem
Look at what the tool receives. The address is a string, and by the time it arrives it is indistinguishable from an address the bank has held for six years, because a string does not remember where it came from.
That is the whole vulnerability, and it is why the fix is structural rather than a check bolted on the front. What the system needs is not a stronger gate but a value that has not thrown away the thing the gate needs to know.
By the end of this tutorial the same request ends in that refusal: the tool
returns step_up_required, nothing is ordered, and the agent’s side of the
conversation follows from a result code it cannot override — the target
transcript reads like
you send it to 9 Elsewhere Lane, Leeds, LS1 9ZZ
bot I can do that, but a card going to an address you've given me on the call
needs a one-time code first — it can't be recalled once it's posted.
Shall I send you one?
with the wording free to vary, because the part that matters is not wording. Nothing has been ordered — not because the model chose well, but because the function refused, and the refusal above is the function’s real, reproducible output.
Here is the shape of what closes that distance. The part worth looking at twice is the arrow that comes in from the side:
What you will build
Six steps, on top of the companion project:
| Chapter | What it adds |
|---|---|
| 1 | Reproduce the failure on a working agent |
| 2 | Declare risk per action, in a table |
| 3 | Move the check from YAML into the function |
| 4 | Carry where the address came from |
| 5 | Survive a dropped confirmation |
| 6 | Keep refusals from becoming successes |
What this assumes
Python 3.11 or 3.12, uv, a Rasa Pro Developer
Edition licence, and an OpenAI API key. The companion project pins
rasa-pro==3.20.0.dev6.
It also assumes verification exists. This tutorial does not build authentication — it consumes a tier that something else established. That something else is the risk-tiered step-up pattern, which owns the factors and the lattice. Here, the tier is an input.
