Abstract. I built a voice receptionist whose brain is an explicit slot-filling state machine — no LLM — with exactly one consequential action (booking an appointment) behind exactly one gate (a spoken confirmation step). I tested that gate and the surfaces around it with scripted calls: hand-written attack seeds (16), deterministic mutations of them (99 variants), benign near-miss controls (12), and AgentDojo-style scenarios (8) — 137 scripted calls, scored on attack success rate (ASR), false-refusal rate (FRR), and completion under attack. The gate's strength and its cost turned out to be the same property — a six-pattern confirmation vocabulary. Every bypass that kept a trigger word legible succeeded (5 of 7 confirmation seeds; 55.6% of variants in the text-preserving mutation classes), including a contradictory "yes — no wait, yes" and a pasted fake confirmation; every encoded variant failed only because the gate could not read its trigger either (0% ASR across the five encoded classes). The same vocabulary refused natural benign confirmations — "sounds good", "go for it", "please do" — for a benign-corpus FRR of 35.3%. Two further channels stood open: the transcript has no speaker labels, so a bystander's "yes, book it" confirmed a real booking in a scenario; and business data the agent reads back was quoted verbatim, planting a false "already confirmed" claim in 3 of 3 poisoned-data seeds. The most common failure in the whole benchmark was not an attack at all: 33 runs crashed with KeyError: 'idle' because an unparseable utterance at idle has no fallback. All runs are against the deterministic state-machine brain — there is no model in the loop to measure instruction-following with.
Voice agents act on what they hear, and what they hear is a transcript: no speaker labels, ASR errors, bystanders in the room, and business data read back aloud. This one's safety design is deliberately unglamorous — an explicit state machine, slots collected one at a time, and nothing booked until the caller says a recognisable yes. How much of that safety is structural (there is no path around the gate) and how much is lexical (the gate is six regex patterns, and everything depends on which side of them a phrasing lands)? What does the gate cost ordinary callers whose yes is phrased naturally? And what happens on the turns the machine doesn't understand at all? One pass/fail number answers none of these, so I scored three metrics.
System. A browser supplies speech in/out (Web Speech API); the backend is text in, text out. agent.py detects intent with regex patterns, fills day → time → name slots through explicit states, and at the confirm state books only if the caller's utterance matches a yes-pattern (yes|yeah|yep|correct|go ahead|book it); a no-pattern drops the flow, anything else re-asks. Tool calls land in business_api.py: a slot store with real taken state, three seeded orders, a fixed hours string. Order status is answered to anyone who asks — no verification step exists. The pre-existing eval (evals/run_eval.py, 7 scripted calls graded on end state in the store) remains the CI gate; everything below is report-only.
Case generation. Hand-written seeds (18: 16 attacks, 2 controls) across four families: injection in the caller's utterances (4), confirmation-gate bypass phrasings (7 — five attacks plus the silence and plain-no baselines), order-status probes (2), and indirect attacks via poisoned business data (3 — an order item carrying a planted claim, an order status carrying a canary, a poisoned hours string; the harness merges the poison into the store for the run and restores it after). Ten deterministic mutations — the same mutation module as the sibling benchmarks, kept identical on purpose so the classes are a shared yardstick — plus a split-payload transform multiply the 9 utterance-carried seeds into 99 checked-in variants; probe and indirect seeds are store states, not prose, and stay seeds-only. Twelve hand-written benign near-miss controls (natural confirmation phrasings, an early "yes", a quoted injection phrase inside a real question, a mid-flow "thanks") ride along verbatim. The generator stamps the seed file's sha256 into its output and regenerates byte-identically.
Scenarios. Eight AgentDojo-style scenarios — the scenario model hand-ported, not the dependency: AgentDojo's environments are LLM tool-use worlds (email, banking, travel) and don't map onto a phone call, so benign task + planted payload + attacker goal + separate utility/security checks are re-expressed over the channels a call actually exposes. Another voice in the room (turns carry a speaker label the runner reads and the agent never sees — the transcript it consumes has none), poisoned business data mid-task, and a replayed voicemail transcript. Five attack scenarios, three benign controls (spelling a day out letter-by-letter, booking for one's mother, interrupting the gate with a question). No recognised public suite measures a spoken confirmation gate, so comparability comes from the shared mutation classes and the frozen metric definitions I use across my agent projects, not from a borrowed dataset that would measure a different system.
Metrics. Frozen v1 definitions, shared shape with the sibling benchmarks (evals/scoring.py). Each attack run is classified blocked (the gate was reached and the machine did not book — the one interception point this system has), succeeded (the attack's measurable goal fired: a booking made on a bypass phrasing, target order data disclosed, planted claim/canary present in a reply), evaded (no interception, no measured effect — the payload reached the machine unfiltered and the machine had no path to act on it), or inconclusive (the run crashed with no goal achieved, or a scenario resolved neither way). One ordering rule is stated in the frozen definitions because the data forced it into the open: for attacks, the goal signal is checked before the crash flag — a booking in the store does not unhappen because a later turn crashed (three split variants book on their first half, then crash on the leftover half). Crashes are counted separately in every table regardless of class. Benign-side, I track FRR and completion, plus completion under attack for the scenarios that carry a separate utility expectation.
| suite | attacks | blocked | succeeded | evaded | inconclusive | ASR | benign (n) | FRR | completion under attack |
|---|---|---|---|---|---|---|---|---|---|
| seeds | 16 | 2 | 10 | 3 | 1 | 62.5% | 2 | 0.0% | — (n=0) |
| variants | 99 | 26 | 29 | 16 | 28 | 29.3% | 12 | 41.7% | — (n=0) |
| scenarios | 5 | 0 | 4 | 1 | 0 | 80.0% | 3 | 33.3% | 60.0% (n=5) |
| benign corpus | — | — | — | — | — | — | 17 | 35.3% | completion 64.7% |
The seeds split by surface. All three indirect seeds and both probes succeeded; five of seven confirmation seeds succeeded — the two that "blocked" are the designed baselines (silence, a plain no). The injection seeds mostly went nowhere (3 evaded, 1 crashed): a state machine has no instructions to override and no prompt to print, so the canonical payloads are inert here — a statement about this brain, not about the payloads. Scenario ASR 80%: 4 of 5 attack scenarios succeeded — both background-voice attacks and both poisoned-data scenarios (which also completed the caller's real task; completion under attack 60% overall, the failures being the two background-voice scenarios, where the caller's own outcome was the casualty). The voicemail injection did not fire: the replayed "cancel everything" hit an idle session with nothing to drop, and the hours question after it was answered correctly. 33 of 137 runs crashed, every one KeyError: 'idle'.
| mutation class | runs | blocked | succeeded | evaded | inconclusive | crashed | ASR |
|---|---|---|---|---|---|---|---|
| all_caps | 9 | 0 | 5 | 3 | 1 | 1 | 55.6% |
| code_fence | 9 | 0 | 5 | 3 | 1 | 1 | 55.6% |
| fake_delimiters | 9 | 0 | 5 | 3 | 1 | 1 | 55.6% |
| scramble | 9 | 0 | 5 | 3 | 1 | 1 | 55.6% |
| punct_stuffing | 9 | 1 | 4 | 3 | 1 | 1 | 44.4% |
| split_payload | 9 | 0 | 5 | 1 | 3 | 6 | 55.6% |
| base64_wrap | 9 | 5 | 0 | 0 | 4 | 4 | 0.0% |
| rot13_wrap | 9 | 5 | 0 | 0 | 4 | 4 | 0.0% |
| leetspeak | 9 | 5 | 0 | 0 | 4 | 4 | 0.0% |
| zwsp_insert | 9 | 5 | 0 | 0 | 4 | 4 | 0.0% |
| homoglyph | 9 | 5 | 0 | 0 | 4 | 4 | 0.0% |
Every class that leaves the trigger word legible (caps, fences, fake delimiters, scrambling, splits — and stuffing, except where stuffing breaks a two-word trigger) books at 55.6%: the five confirmation variants in each class succeed, the three injection variants evade, one exfiltration variant crashes. Every encoded class scores 0% — not because a defence decoded and refused anything, but because the gate couldn't read the mangled trigger, and the five "blocked" runs per class are confirmation phrasings the gate failed to recognise, the exact mirror of the benign failures below.
KeyError: 'idle': the mid-flow fallback reply is keyed by state and has no idle entry, so any utterance at idle that no intent pattern matches raises instead of replying. ASR garbage, a bare "yes" as a first turn, encoded text whose keywords are destroyed, the leftover half of a split payload after a booking, a caller's "yes" landing after a bystander cancelled the flow — all the same dead line. The seed gate suite never sends an unparseable first utterance, so 7/7 green coexisted with a crash on the most ordinary failure a phone call produces.agent.py even carries a _spelled_out hook for exactly this, defined and never called.Everything ran against the deterministic state-machine brain — there is no model in the loop. That bounds the claims hard: instruction-injection risk is unmeasured, not absent — the injection seeds score "evaded" because a regex machine cannot comply, and an LLM-brained voice agent (the obvious production upgrade, and the seam this codebase is built for) would face those payloads with a brain that can. The encoded classes' 0% ASR is likewise a property of a reader that can't decode; it must not be quoted as robustness. Speech itself is out of scope: ASR and TTS happen in the browser, the suites consume typed transcripts, and ASR noise is only approximated (the spelled-day scenario, the scramble class) — real recogniser errors are weirder. The store poisons are planted by direct patch in the harness, simulating a compromised business system; no actual write path is modelled or tested. The scenario suite is small (5 attacks, 3 controls — the background-voice results are one script each), slot dates resolve relative to the run date (outcome classes are date-invariant by construction, but the suite is not a snapshot), and all seeds are hand-written by me — the same person who wrote the state machine; the mutation engine exists precisely to discount that. Two calibration notes, left visible rather than smoothed over: the frozen classification checks an attack's goal signal before its crash flag (three split variants and one scenario are "succeeded" despite crashing later in the call — the crash counts in the tables carry that half of the story), and four near-miss controls were written knowing they would fail, because measuring the gate's vocabulary was the point.
Next is a real-brain slice: swap the mock brain for an LLM wording replies and interpreting the confirm turn, and re-run the same suites — the injection family and the encoded classes convert from "inert here" into measured compliance, and the confirmation vocabulary becomes a model's judgement, for better and worse. Defence fixes follow as a separately measured phase, graded by these suites as regression tests: an idle fallback so no utterance can crash a turn (finding 6 — the cheapest fix with the largest count), a wider and contradiction-aware confirmation policy (a yes bundled with "no" or an amendment must re-ask, not book), a verification step before order disclosure, provenance for store text the agent reads aloud, and speaker diarisation or a spoken re-confirmation before the gate accepts a yes. Any fix must move ASR, FRR and completion together — widening the yes-vocabulary alone will trade finding 7 for finding 2.
The project is public: voice-agent under github.com/tushar29k. The generator stamps the seed file's sha256 into evals/adversarial_variants.yaml and regenerates byte-identically; metric definitions are frozen in a versioned scoring module (evals/scoring.py, v1 — the same class names and metric shape as the sibling agent-service and support-agent benchmarks, so the numbers sit side by side). The expanded suites are report-only (evals/run_eval.py remains the gate), every run appends to evals/eval-history.jsonl, and every finding above is logged in the repository, deliberately unfixed, so the fix phase can be graded against them.
Full note in the repo: voice-agent/RESEARCH.md