Independent audit · Arnow Solutions · 5 Nov 2026

Twilio voice agent + online intake, verified against the live systems

A second auditor's pass over the same stack. Every claim below was tested against the running phone line, the deployed service, and the public pages — not read from source alone.

Scope Twilio phone leg, web voice page, intake pages
Twilio calls on record 19 legs, all 5 Nov
Account balance $20.00 — nothing billed
Deploy drift none — md5 local ↔ VPS match

01What is actually running

Phone number +1 929 374 2797 ("Arnow Voice Intake") → webhook POST /twilio/voice → media stream wss://voice.srv1738752.hstgr.cloud/ws/twilio → the same Pipecat pipeline as the browser leg: Deepgram Nova-3 → DeepSeek deepseek-chat → Aura-2 aura-2-thalia-en.

02The Twilio leg — wiring right, guardrails missing

CheckStateEvidence
Webhook URL + methodOKPOST /twilio/voice on the number, HTTPS, returns Connect/Stream TwiML that passes caller ID as a custom parameter
Serializer ratesOK16 kHz pipeline vs 8 kHz Twilio set explicitly; DeepSeekLLM.supports_developer_role=False
Signature validationmissingNo X-Twilio-Signature check on either the webhook or the socket — anyone who finds the socket consumes paid minutes
Concurrency guard on phonemissingMAX_CONCURRENT and MAX_PER_IP_HOUR are enforced only in /api/offer. /ws/twilio starts a pipeline unconditionally
Fallback TwiMLmissingNo voice_fallback_url: if the service is down the caller gets a raw Twilio error tone, not a "we'll call you back" message
Status callbacknoneNo call events; failures only visible by pulling the CDR
SMSenabled, unhandledNumber has SMS + MMS capability, sms_url empty — inbound texts are silently dropped (the UI copy says "text this number" on failure screens of a sort that don't exist)

Call log reality check

Twilio's CDR shows 19 legs: 2 handset calls (15 s and 21 s from a 34x number) and 17 paired self-call legs (the console calling the number from the number — each shows as inbound + outbound with identical durations). Nothing from a real prospect yet. That's a statement about a day-old line, not a defect.

03Defects, probed live

P0

The phone leg has no rate/concurrency ceiling at all

The web offer endpoint checks _active >= MAX_CONCURRENT and the per-IP window before minting a connection. The Twilio WebSocket handler has neither check — every inbound call spawns a full pipeline on a 4-vCPU box that is already told to cap at 3. Fifth simultaneous caller doesn't queue; it starts.

Fix: move the semaphore into run_session (shared by both transports) or check _active at the top of twilio_ws and reject with a busy TwiML. Effort: ~30 min.
P0

Inbound requests are never authenticated

Neither /twilio/voice nor /ws/twilio validates X-Twilio-Signature (the credentials are loaded but only used to sign the outbound serializer). The socket trusts ?from= as caller ID, which means the "welcome back, [name]" greeting and the read-back-the-number prompt can be spoofed from any WebSocket client. Also no rate limit on that path — every spoofed session burns paid TTS/STT.

Fix: validate the signature on the webhook; on the socket require a signed token (or check the call exists in Twilio's API via callSid). Effort: 1–2 h.
P0

The admin page has a stored XSS and the token rides in the URL

bot.py's admin() builds the leads table with plain f-string interpolation for name, email, phone, summary, commitment etc. Only the research blob is HTML-escaped. Any caller-supplied string is live HTML in the operator's browser — a spoken "my name is <script>…" becomes script when Shareef opens /admin. And because the token is in the query string, the exploit fires on the very click of the shared link.

Fix: wrap every interpolation in html.escape(); move the token to a POST-gated form or an HttpOnly cookie; add Content-Security-Policy on that response. Effort: 30 min.
P0

voice.db is in the blast radius of the deploy and unbacked

The SQLite store lives at /opt/arnow-voice/data/voice.db, inside the same directory rsync/scp targets on every deploy. The workspace source tree has no data/, so any tree-sync that uses --delete (or copies the repo over /opt/arnow-voice) erases the database; the service then recreates an empty schema at next restart. That is exactly how the only real lead (22:02 on 5 Nov) was lost. find / -name "voice.db*" proves there is exactly one copy on the host.

Fix: move the DB to /var/lib/arnow-voice/ (outside the deploy target), and add a nightly sqlite3 .backup. Effort: 30 min + one cron line.
P1

save_intake has no phone field, but the prompt asks her to "save the new number"

Prompt section 8 ends with "if they correct it, save the new number." The save_intake tool schema has name, email, business, category, social, timeline, decision_maker, referral, commitment, project_details — no phone. The tool literally cannot obey the prompt, and the number-live update claim on the README doesn't happen for number corrections in-call.

Fix: add phone to the schema with a "caller's preferred callback number" description, and let the API upsert it.
P1

Prompt section 8 hardcodes the business number as "the caller's number"

"I've got you at nine two nine, three seven four, two seven nine seven — is that the best number?" is inlined into the system prompt for every call. On the web channel there is no number at all (she reads the studio's number back as the caller's). On the phone channel there is a real caller-ID injection note, but it contradicts the hardcoded paragraph.

Fix: delete the hardcoded digits from section 8; for the phone channel, let the caller-ID system note provide the only number (which is where the read-back instruction already lives).
P1

Phone greeting doesn't match the phone channel note

GREETING["phone"] is set to the same string as web, so the actual opener is "Hi, this is Kimberly, the AI assistant at Arnow Solutions…" while CHANNEL_NOTE["phone"] separately instructs her to open with "Thanks for calling Arnow Solutions, this is Kimberly." The prompt contradicts itself, and on a phone line the widget-flavored opener reads as a mistake.

Fix: set GREETING_PHONE (or delete the note's instruction and let the greeting carry it). Also GREETING_WEB/GREETING_PHONE are only knobs if the .env actually has them — it doesn't, so they fall back to code defaults today.
P1

Four-digit PIN is the identity check, and its defense resets per session

resume_caller looks up find_by_pin, which returns the caller's name, business, email, notes, and the last four transcript turns. The 3-try counter lives in the call state — a new session resets it. Anyone who can call can iterate three digits per call at 6 calls/hour/IP (a limit that only exists on the web endpoint anyway; the phone leg has none of these).

Fix: a global attempt ledger keyed by caller ID or IP, plus a lower data payload on lookup (e.g. don't return email/notes until a second factor). Or replace the PIN with a spoken second factor (company name).

04The online intake pages

The site currently has three intake surfaces, and none of them reach the voice agent:

SurfaceEndpoint usedState in prod
/contact.html (static contact form)POST /api/intakeWorks end-to-end. honeypot + regex validation + IP rate-limit + SMTP delivery; returns ref AS-2026-NNNN. Verified end-to-end from outside the box.
/intake-line.html (conversational hourly-cost counter)tries POST /api/v1/api/sessions → /api/v1/sessions → /api/sessionsAll three 503 — the page cannot submit. Nothing from it appears in the relay DB. This intake is decorative right now.
/talk.htmln/a (nav page only)Body CTA → /contact (503), footer → /process (503), /voice-demo (missing). A visitor pressing the button hits a server error.
Voice widget (widget.js)POST /api/offer on voice.srv…Not referenced from any live site page. Site CSP connect-src and permissions-policy: microphone=() both forbid it.
P0

The CSP + microphone policy block the agent from the site it belongs on

Every page on arnowsolutions.com ships permissions-policy: geolocation=(), microphone=(), camera=() and a CSP whose connect-src allows 'self', analytics, and intake.srv… — not voice.srv…. Both the widget and the browser SpeechRecognition mic button on /intake-line.html are dead on arrival. The chip "Answer by voice if typing's a hassle" cannot work in Chrome.

Fix: change to microphone=(self) and add voice.srv1738752.hstgr.cloud to connect-src — two lines in the Hostinger config where those headers are coming from (they are not in the site repo; a deployed .htaccess or the hPanel config sets them globally).
P1

Honeypot is client-side only on the contact form

The hidden company_site field is checked in browser JS (contact.js). The relay on the VPS (/opt/intake/app.py, do_POST) never reads it — a POST with the trap field omitted is accepted and emailed (verified with a probe submission written and then deleted). Adding a four-line server-side check closes it.

P1

/intake-line.html, the strongest piece of copy on the site, cannot deliver

The conversational intake's JS (assets/js/consultant-intake.js) posts a full discovery session payload with flow / contactName / contactEmail / seedAnswers / autoSubmit:false and then calls finalize. None of that route exists in production. Either serve the API it was written against (the VPS runs /opt/intake/app.py — a sessions endpoint would be ~100 lines), or rewrite its final send step to the /api/intake shape the contact form already uses. Until then the site's own best-converting copy is a dead end.

05Two lead stores, one person

The voice agent's leads live in voice.db (voice.srv host); the written intake's leads live in /opt/intake/data/intake.db (site relay). Two schemas, two reference formats (4-digit PIN vs AS-2026-NNNN), no shared identifier. A caller who spoke to Kimberly and then filled the form is two people to the business.

Fix: when both stores are next touched, join on email (both capture it) and render one merged view for the operator. Not urgent; do it in the same pass as the export feature.

06Cost, from the live vendors' published rates

LineRatePer 5-min call
Deepgram Nova-3 (streaming STT)$0.0077/min$0.039
Deepgram Aura-2 (TTS, per 1k chars)$0.030$0.045–0.08 (TTS is the dominant cost — keep replies short)
DeepSeek deepseek-chat~$0.28/M input~$0.01
Twilio inbound$0.0085/min$0.043
Total—$0.14–0.17 / call (~$0.03/min)

Twilio account itself: $0 billed so far (the account is new; $20 trial balance). Self-test calls consumed Twilio minutes but the metered invoice hasn't landed yet.

07What works (crediting it)

08Remediation, ordered

OrderItemEffort
1Move voice.db out of the deploy target + nightly .backup30 min + cron
2Escape admin HTML + move token out of URL + CSP30 min
3Concurrency gate on the phone leg + Twilio signature validation + fallback TwiML2–3 h
4Fix CSP + microphone policy on the site; conflict-free routing for the widget30 min + deploy
5Remove hardcoded digits from prompt §8; add phone to save_intake schema; split GREETING_PHONE1 h + one test call
6Wire /intake-line.html to a live endpoint2–4 h
7Global PIN attempt ledger, and don't return email/notes on lookup2 h
8Decide what SMS does (either serve it or disable the capability)15 min

Audit method. Read bot.py / intake.py / widget.js / index.html / README.md in full locally; md5 diffed against the VPS deploy; live service probed via /health, systemd unit, Traefik config, UFW state; Twilio REST API pulled for the number config, incoming webhook, CDR (19 legs), usage and balance. arnowsolutions.com crawl from outside (home, contact, talk, intake-line, widget.js, consultant-intake.js) with CSP + Permissions-Policy headers read per-page. Relay source read on the VPS (/opt/intake/app.py); two probe submissions written, verified in intake.db, then deleted — no residue left. My test artifacts were removed; the operator's own entries were untouched.

This report is scoped to the voice agent + intake surfaces. The hosting frontend (Hostinger), the Umami analytics, and everything else on the VPS were not audited here. Independent from, and published separately to, another audit run of the same stack on the same day.