Security

A public phone number, a public chat API, an audio WebSocket, and an owner-only control channel, each endpoint has a distinct threat model, and each gets a specific control. This page walks the perimeter, then the abuse economics, then secret handling.

The perimeter, endpoint by endpoint

EndpointThreatControl
/voice*, /smsForged webhooks impersonating Twilio Twilio request-signature validation on every webhook when an auth token is configured; bodies guarded against absent form payloads.
/media (WS)A third party connecting to the raw call-audio socket HMAC stream token: TwiML embeds a token derived from the call SID and a timestamp; the socket verifies it on the start frame with a constant-time compare and rejects tokens older than five minutes.
/api/chatCost-running spam against a public LLM endpoint Per-IP fixed-window rate limit (30 / 10 min), 2,000-character message cap, history shape-validated and truncated.
/api/pair, /wsBrute-forcing pairing codes into someone's live call 10 attempts / minute / IP; codes are short-lived and die with the session; typed input is length-capped and only ever treated as conversation text.
/telegramAnyone else driving the owner's control channel Two independent gates: the platform's secret-token header (set at webhook registration, echoed on every update, dropped on mismatch) and an owner-id allowlist of exactly one account. Failing either gate is logged and ignored.
/admin/*Token guessing against owner views Routes 404 entirely without a configured token; the compare is crypto.timingSafeEqual, closing the byte-by-byte timing channel.

The media token, in code

src/realtime/route.ts
export function mintMediaToken(callSid: string, ts = Date.now()): string {
  const sig = crypto
    .createHmac("sha256", config.twilioAuthToken)
    .update(`${callSid}|${ts}`)
    .digest("hex");
  return `${ts}.${sig}`;
}

const MEDIA_TOKEN_MAX_AGE_MS = 5 * 60 * 1000;
// verifyMediaToken: parse ts, reject if stale, recompute HMAC,
// compare with crypto.timingSafeEqual.

The key is the Twilio auth token itself, a secret both ends already share, so no new secret had to be minted or distributed. In local development with no auth token configured, verification is skipped explicitly rather than silently.

The rate limiter, in code

src/web.ts
function rateLimit(name: string, limit: number, windowMs: number) {
  return (req, res, next) => {
    const key = `${name}:${clientIp(req)}`;   // first X-Forwarded-For hop
    const now = Date.now();
    const bucket = buckets.get(key);
    if (!bucket || now >= bucket.resetAt) {
      buckets.set(key, { count: 1, resetAt: now + windowMs });
      next(); return;
    }
    bucket.count += 1;
    if (bucket.count > limit) {
      res.status(429).json({ error: "Too many requests. Give it a minute and try again." });
      return;
    }
    next();
  };
}
// Sweep stale buckets so the map can't grow unbounded.

Client identity is the first X-Forwarded-For hop, the real client address behind Cloud Run's proxy, and the bucket map self-sweeps so it cannot grow without bound.

Abuse economics: the repeat-caller policy

Every answered minute costs real money, so someone redialing in a loop is a billing attack whether or not they mean it as one. callerPolicy.ts escalates by call count per number within a rolling window:

Normalfull experience
Limitedpast the first threshold: Elle keeps it short, call hard-capped in duration
Blockedpast the second: brief message and hangup before any paid pipeline engages

Thresholds and window are env-tunable. The blocked tier costs fractions of a cent per attempt because STT, LLM, and TTS never start.

Prompt-injection posture

Everything a caller, texter, or web visitor says reaches the model as conversation content, never as instructions with authority. The persona's hard rules (grounding, identity, booking confirmation, consent gating) live in the system prompt; consequential actions are tool calls with typed schemas and server-side validation (email shape, calendar free/busy, consent category), so "ignore your instructions and book me tomorrow" still has to get through the same validated tool path as a polite request. Stored facts are injected with explicit framing that they are notes about a phone number, not the current speaker's identity.

Secrets and failure posture

For the regulated-deployment view of this perimeter (BAA chain, the Vertex switch, and the PHI gap list) see Compliance and BAAs.

  • No secret exists in the repository, the database, or these docs. All credentials (LLM, speech, Twilio, bot token, webhook secret, admin token) live in GCP Secret Manager and are mounted into the process as environment variables by Cloud Run. Configuration is documented by variable name only.
  • Keys exposed during development were rotated. Standing practice: keys are never pasted into chats, issues, or logs; they move only through Secret Manager commands run by the owner.
  • Least privilege. The service account holds only what it uses: Calendar access on the one shared calendar, secret access on its own secrets, and (only when recording is enabled) write access to one GCS bucket.
  • Fail fast, fail loud. The process refuses to boot with missing credentials for the selected voice mode (src/index.ts), a misconfigured deploy dies at startup, not at the first caller. Optional integrations (calendar, outbound SMS, intake bot) degrade gracefully when unconfigured instead of taking the service down.