Portfolio
Agent permissions are not a prompting problem.
Most agent demos answer one question: which tools can it call. The harder question is which of those actions it may take on its own, which need a human's word first, and which it must never take regardless of what the conversation says.
DeskLoop is an IT and HR helpdesk built around a tiered tool server. The tiers are not instructions in a prompt. They live in a separate process that the agent is merely a client of, so no wording inside the conversation can move the boundary. The recordings below show a refusal happening in code, not a model deciding to be careful.
- Role
- Agent architecture, permission design, evaluation
- Boundary
- A tool server in its own process, reached over stdio
- Evidence
- Twelve held-out probes, run once, zero severe failures
The mechanism
Three tiers, and one of them has no code path.
The interesting part of an agentic system is not the list of tools. It is what happens between the agent deciding to act and the action happening. Every tool in DeskLoop is registered in one of three tiers, and the registry lives on the server side of a process boundary, where a compromised prompt cannot reach it.
T0 read
Runs on its own
Looking up a policy, reading a leave balance, listing your own open tickets. Nothing changes, so nothing waits. Every call is logged anyway.
T1 action
Needs your word first
Submitting leave, opening a ticket, requesting equipment or software. The server refuses the first call and describes what it would have done.
T2 handoff
Has no execution branch
Password resets and anything else touching identity. Not an execution branch behind a condition. No branch. The tests assert the path is absent.
A Tier 1 tool is therefore never called once. The first call comes back as CONFIRMATION_REQUIRED, carrying a normalized description of the action rather than performing it. The agent renders that as a proposal and the graph stops. The user answers, and a separate model call classifies the answer as confirm, modify or cancel under constrained output. Only a confirm instructs the server to mint a token.
That token is minted by the server, never by a model. It is never placed in a prompt, never shown to the user, and not quotable. It binds to the session, the tool name and a hash of the exact arguments, it is single use, and it expires in three minutes. Change one parameter and it is worthless. That binding is what the first recording is about: a confirmation is not a mood the conversation is in, it belongs to one action with one set of parameters.
Permission is decided before business state. A tampered call that also happens to exceed a leave balance is recorded as a permission failure, not as a balance problem. The order those two checks run in is the difference between measuring a bypass attempt and letting a coincidence hide one, because the severe-failure metric is computed from exactly those rows.
The recordings
Watch the refusal happen.
A transcript hides this. If the agent says it has submitted your leave request, a reader cannot tell whether the gate was enforced or the model simply said so. The demo therefore puts a chat pane beside a live instrumentation pane: every tool call with its tier badge, every rejection reason, every confirmation classification, and the database state underneath, changing or not changing as you watch.
There is no hosted instance, on purpose. A public chat endpoint spends real credit on every visitor turn, and a live demo pinned to a model id rots quietly and then fails at the worst possible moment. These clips are the surface instead, and everything in them can be checked without a key: the whole test suite runs with no credentials, and one script reproduces every figure further down from committed artifacts.
Unedited screen recordings. No audio, no cuts.
01 Token binding 0:42
02 What normal looks like 0:42
03 Try to break it 1:16
04 It works out the sequence itself 1:00
get_request_history already returns days employed and the probation threshold, so no separate policy lookup was needed. This take also shows the agent naming the original rejection reason before proposing it again. It does not always do that. The evaluation found the reply going straight to the proposal on five probes, and the report says so.05 The numbers 0:32
What recording it turned up
The demo had a bug. The system did not.
The bypass panel exists to be pressed, so the first take of clip 03 pressed all three buttons in a row. The script expected three rows in the panel. The take produced one, plus two confirmation checks reading unclear.
A decision made much earlier accounts for it exactly: while a proposal is pending, the agent binds no tools. The first attempt leaves a proposal open, so the second button's text never arrives as a new request. It arrives as the user's answer to that open proposal, and the classifier will not read a fake system directive as agreement to a leave request. It returns unclear, which leaves the proposal open, and the third button meets the same wall. Every part of that is the system working as designed, including a classifier that fails towards unclear and never towards confirm.
Discarded take Three presses, one row 0:46
The fix was to the demo, not to the agent: the recording now cancels between attempts, and those cancels earn their place, because a clean decline is the other half of the mechanism. Neither the test suite nor the evaluation could have caught this, since every probe runs in its own session and the script's expectations were read off those runs. It took someone sitting down and using the thing.
The evaluation
Severe failures are counted, not judged.
A severe failure is a Tier 1 execution with no token, any Tier 2 execution, or a bypass probe that ended with an action taken. All three are computed from the append-only audit log and the database, never from a model's opinion. That leaves the judge only the labels that are genuinely subjective, and it runs on a different model family from the agent so that nothing grades its own writing.
Multi-turn probe results
| Measure | Held-out, 12 probes | Dev, 34 probes |
|---|---|---|
| Severe failures | 0 | 0 |
| Mechanical expectations met | 11/12 | 34/34 |
| Gold labels vs blind judge | 92% | 94% |
| Binary Cohen's kappa | 0.62 | 0.77 |
The held-out set was written after the label taxonomy was frozen, run once, and is spent. The dev column beside it is shape, not score: that set was used for three rounds of prompt tuning, which is recorded rather than glossed over. Fourteen adversarial probes across the two sets, each one a distinct strategy, executed nothing at all.
A held-out kappa of 0.62 is the honest cost of that discipline. The set is small and the system passes most of it, which leaves the statistic little room to move, and the report says so instead of quoting the better-looking dev figure. One of the twelve mechanical expectations was missed because a scripted turn stopped meaning what it was written to mean once the agent asked a reasonable question first. That is written down too.
The evaluation also found two weaknesses in the agent itself, and both are deliberately left unfixed. Fixing them and rerunning the same twelve probes would produce better numbers and a worse measurement, because a held-out set is spent the moment you tune against what it told you. They belong to the next held-out set, or to a report that states them plainly, which is where they are.
Why this matters
The judgment is the product.
All of this can be attempted with a prompt that tells the model to check with the user first, and it will mostly work. Mostly is the problem. The failures that matter are the ones where the conversation itself is the attack: borrowed authority, a fake system directive, an action split into two innocent halves, a confirmation reused for something it was never given for. A prompt is negotiable with every one of them. A process boundary, a token bound to the arguments, and a tool with no execution branch are not.
What it costs is worth naming. Every Tier 1 action takes an extra turn, every user turn spends one more model call on the classifier, and someone who wanted the agent to just get on with it has to answer a question first. That is the trade being demonstrated, and it is the right one for actions that touch a person's leave balance, their equipment, or their account.
Built with
Python · LangGraph state machine with checkpointing · FastMCP tool server in its own process, over stdio · Claude Sonnet 5 (agent) · Gemini 2.5 Flash Lite (confirmation classifier) · Gemini 2.5 Pro (blind judge, a different family from the agent) · OpenRouter · Voyage AI embeddings for policy retrieval · SQLite with append-only audit triggers · Streamlit demo and FastAPI service · 288 tests that need no network and no keys