Blanket per-endpoint limits would have been the wrong shape here. Every REST/WS/MCP surface is owner-only — an unauthenticated request is rejected by resolve_bearer/is_owner before any handler runs — so limiting them would mostly throttle the single legitimate operator, and real spend control for outbound calls is already max_concurrent_calls in gateway.make_call. What is genuinely exposed is the handful of /auth/* routes that must answer before an identity exists. /auth/callback and /auth/refresh-callback each make an outbound token exchange with Casdoor on every request; /auth/me opens a DB session and runs a token lookup. All are free to trigger and none are cheap to serve. /auth/logout is left unlimited — it builds a redirect URL and does no I/O. Not a defence against credential guessing: PATs are secrets.token_urlsafe(32) (256 bits) compared by SHA-256 digest, so brute force was never the threat. This is about unauthenticated work an attacker controls. Fixed-window, in-process, no new dependency — one operator and one process make a shared counter store infrastructure without a purpose. The bucket store is bounded and evicts oldest-first, since an unbounded map keyed by source address would itself be the exhaustion vector. The limiter keys on the socket peer and deliberately ignores X-Forwarded-For. That header is attacker-controlled unless a trusted proxy overwrites it, and this app establishes no such trust; keying on it would let one client present as thousands and make the limiter worse than useless. Behind the estate's reverse proxy the limit is therefore per-proxy, not per-caller — correct for exhaustion and honest about what it can enforce. Per-caller limits need an explicit trusted-proxy config, noted in CLAUDE.md so it isn't added silently. Verified against a real server: exactly 30 requests pass, then 429 with Retry-After: 60, while an owner-gated route serves 40/40. The 429s appear in the JSON access log with queryable status_code and client_addr, so an attack is visible in Loki. The wiring test identifies the dependency by qualname rather than string search, and was mutation-checked by removing the limit from /auth/me. Also documents 401/403/429 in the API reference — 401 and 403 have existed since auth landed but were never in the status-code table. Phase 4 is now complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
CLAUDE.md — Hold Slayer 🔥🐾
Red Panda Standards for the Hold Slayer telephony gateway. This is an AI-powered PSTN gateway: it places real phone calls, navigates IVR menus, waits on hold, and rings a human's desk phone when a live person answers. It also answers inbound calls with an AI receptionist and smart routing.
It dials real numbers on a real SIP trunk and may incur telephony charges, and an AI agent drives it. Treat every change through that lens: a bug here isn't a 500, it's an unwanted phone call — or a refused emergency call that should never have been attempted in the first place. Read this file before touching call placement, auth, the SIP thread boundary, or the emergency guard.
Lead with a paw print in this repo.
The shape of this thing (read once, then it's obvious)
One FastAPI process exposes four surfaces over the same port: REST (/api/v1/*),
WebSocket (/ws/*), an MCP server (streamable HTTP at /mcp/), and the
built SvelteKit dashboard at /. All four are owner-only, gated by Casdoor
SSO (browser JWT) or an owner-minted PAT through one shared resolver.
Under them sits a composition root in main.py's lifespan: the
gateway and every service are constructed and wired there, then hung on
app.state. Nothing constructs its own dependencies — if you need a new
service, build it in the lifespan and pass it in.
Below the services is the part that makes this app unusual: a SippyB2BUAEngine
that runs the SIP/PJSUA2 event loop on its own OS thread, not the asyncio loop.
This is the highest-leverage invariant in the codebase. See
the concurrency rule — the README's
"single-process async" line is a simplification; there are two execution
contexts and exactly one funnel between them.
REST / WS / MCP / Dashboard (asyncio, FastAPI)
│
composition root (lifespan) → services → gateway
│
SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus)
└── Sippy thread (UA objects, ED2 dispatcher)
↑ crossed only via _post_from_ed / _run_on_sippy
Red Panda Approval™ — what it means here
- Fresh Environment Test —
cp .env.example .env, setDATABASE_URLand eitherCASDOOR_*+OWNER_NAME(SSO) orCASDOOR_ENABLED=falsewithHOST=127.0.0.1(dev-owner, loopback only),pip install -e ".[dev]",uvicorn main:app. It must boot to a clear log banner or exit with a human-readable reason (see_check_startup_config/_handle_db_errorin main.py). It must never boot into a state where it silently can't place calls — that's whyuse_mock_sipis opt-in and an unconfigured trunk fails startup. - Elegant Simplicity — the composition root wires; services do one job; the thread boundary has exactly one funnel each way. Don't add a second path across the thread line, a second auth mechanism, or a service that reaches into another service's internals.
- Observable & Debuggable —
/healthis honest: it reportsdegradedwith the reason (mock engine, unregistered trunk, DB down, STT/TTS unreachable) rather than a green light that lies. Keep it honest. Events flow through the typedEventBus; new call-lifecycle facts become typed events, notprints. - Consistent Patterns — config via pydantic-settings sub-configs; MCP tools
return formatted strings; REST returns Pydantic models; DB access via
session_scope(). Match the neighbours. - Actually Works —
pytest tests/ -v(189 tests across 19 files). A change to call placement, routing, the classifier, or auth needs a test. The suite runs against SQLite (aiosqlite) and the mock SIP engine — no trunk, no Postgres required to test.
Invariants that must not be weakened
These are safety- and correctness-critical. Loosening one is never a casual
refactor — it needs an explicit rationale and, where it deviates from a stated
standard, a note (there is no docs/EXCEPTIONS.md yet; if you start
accumulating documented deviations, create one rather than letting them go
unrecorded).
-
The emergency-number guard is absolute.
is_emergency_number()in core/dial_plan.py blocks911/9911/112(and their E.164 forms, whitespace/dashes stripped) atgateway.make_call, before the concurrency check and before any SIP action. Every dialling path — RESTmake_call, MCPmake_call, receptionist call-back, transfer to an external number — must pass through a guard that refuses these. An AI agent must never place an emergency call, and API calls carry no E911 location. Do not add a dial path that bypassesmake_call's guard. If you add a new outbound path, it calls the guard first. See the safety rule. -
The concurrency cap is real spend control.
max_concurrent_calls(default 4) caps simultaneous outbound calls ingateway.make_call. It's checked after the emergency guard, before creating the call. Don't remove it or move it below call creation. -
The Sippy thread boundary is crossed only through the two funnels. Sippy UA objects and the
ED2dispatcher live on the Sippy thread; legs, bridges, and the event bus live on the asyncio loop. Cross thread→loop only via_post_from_ed(→run_coroutine_threadsafe→ the single_on_engine_eventfunnel) and loop→thread only via_run_on_sippy(→ED2.callFromThread). Never touch a Sippy UA object from the loop; never mutate loop-owned state from a Sippy handler. This is the whole reason the app is thread-safe. -
Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver. The browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted PATs (
hs_pat_…).resolve_bearerin auth.py turns either into aUser, andis_owner/get_current_ownergate every surface to the single operator (OWNER_NAME) — non-owners get 403. Dev mode (CASDOOR_ENABLED=false) resolves the dev owner and is only permitted on a loopback bind;_check_startup_configrefuses SSO-off-loopback and SSO-on-with-missing-config. Keep those refusals — a silent dev-owner-open-on-0.0.0.0 is the failure mode they exist to prevent. See the auth rule. -
/healthtells the truth.healthyrequires a real (non-mock) engine, a registered trunk, and a reachable DB. Don't relax it to make a probe go green; a gateway that can't place calls is not healthy, and the dashboard/operator needs to see that. -
The database is the source of truth for history; live state is in memory. Active calls live in the
CallManager; completed calls + transcripts are persisted on hangup viacall_persistence. MCP/REST history and summaries read from the DB throughsession_scope(). Don't confuse the two — a call that ended is gone fromactive_callsand only exists in the DB.
Config — pydantic-settings, nested, singleton
Config is Settings in config.py: a root BaseSettings with
nested sub-config models (SIPTrunkSettings, LLMSettings, TTSSettings,
ReceptionistSettings, …), each with its own env_prefix
(SIP_TRUNK_, LLM_, TTS_, RECEPTIONIST_, …). Read config through
get_settings() (a cached singleton) — don't call os.environ.get for
Hold Slayer settings, and don't construct Settings() yourself outside that
accessor.
Estate note (not a defect): unlike the
KERNOS_/ARGOS_/NIKE_-style single-prefix services, Hold Slayer has no one umbrella prefix — root vars (DATABASE_URL,HOST,MAX_CONCURRENT_CALLS,OWNER_NAME) are unprefixed and each subsystem carries its own (CASDOOR_for the SSO connection). That's a deliberate readability choice for a config with this many subsystems; keep new vars consistent with the sub-config they belong to, and if you add a new subsystem, give it its own sub-config + prefix rather than piling flat vars onto the root.
Secrets (casdoor.client_secret, sip_trunk.password, llm.api_key,
tts.api_key) are
SecretStr — keep them so, and read via .get_secret_value() only at the point
of use. Every new var also needs a row in .env.example and the
README's config table. See the config rule.
.env hygiene: .env is gitignored and holds real secrets — never commit
it, never treat the local .env as a template. Only .env.example
(placeholders) is committed.
MCP tools — formatted strings, and the error convention to know
MCP tools (mcp_server/server.py) return plain
human-readable strings (not JSON, not Pydantic models — that's the REST
layer's job). Resources (gateway://status, gateway://call-flows,
gateway://active-calls) return JSON strings.
There's a deliberate but uneven error convention worth understanding before you add a tool:
make_callraisesToolErroron a bad request (emergency number, bad mode, cap hit) — a hard failure the assistant should treat as an error.- Most read/lookup tools return an error string (
"Call … not found.","Error looking up …: {e}") instead of raising — a soft "here's what happened" the assistant reads as content.
When you add a tool: raise ToolError for "you asked for something invalid or
unsafe"; return a plain string for "I looked and here's the (possibly empty)
answer." The gateway is resolved lazily per call via require_gateway() (it
raises ToolError while the gateway is still starting) — keep that, because the
MCP app is mounted before the lifespan builds the gateway. See
the MCP rule.
What's real vs. stubbed (don't mistake one for the other)
- PJSUA2 media pipeline runs in stub mode unless the
pjsua2bindings are built from pjproject (not pip-installable). Signaling works; audio routing/recording is a no-op stub without them. Code that assumes real audio must degrade honestly, and/health/engine-mode must reflect stub vs real. - The mock SIP engine (
MockSIPEngine) must be asked for (USE_MOCK_SIP=true). It exists for tests and local dev. Production must not silently run on it —/healthreportsengine: mockand refuseshealthy.
Known gaps (flag, don't fold — these are not your task unless asked)
Surfaced honestly so you don't rediscover them as surprises. The README's Phase 4/5/6 checklists track most of these; don't fold fixes into unrelated work — raise them.
No structured JSON logging.Done:LOG_FORMAT=jsonin core/logging_config.py, applied at import and again inlifespanbecause uvicorn installs its own handlers (propagate=False) after importing the app. The access log is included, withstatus_codeas a number so Loki can range-filter it. Text remains the default; the Docker image sets json.- No
/metricsendpoint and no Prometheus. Unlike the metrics-bearing estate services, there's no exposition endpoint here yet. - No health-probe access-log filter. Every
/healthpoll hits the access log. Other estate services suppress probe noise; this one doesn't. No rate limiting on API endpoints.Done, but narrowly: only the unauthenticated/auth/*routes are limited (core/rate_limit.py), because every other surface is already owner-gated and a limit there would throttle the sole operator. The limiter keys on the socket peer, notX-Forwarded-For— behind the estate's reverse proxy that means per-proxy, not per-caller. Per-caller limits need an explicit trusted-proxy config; don't silently start trusting the header.- Docker: single-image
Dockerfile+docker-compose.yaml(app +postgres:17) ship in-repo; the Gitea CI (cve-scan-docker-build.yml) builds the image on push tomain. The compose stack requires SSO enabled (published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo. - A committed
.DS_Storeis not present (good), but do checkgit statusstays clean of OS cruft;.gitignorealready lists it.
Working here
- Run:
uvicorn main:app --host 0.0.0.0 --port 8000(orpython main.py). The CLI--portwins oversettings.portin the startup banner logic — a real gotcha the banner code already accounts for; don't "fix" it into disagreement. - Test:
pytest tests/ -v. Fast, no external services (SQLite + mock SIP). - Lint:
ruff check .(line length 100,py312target). - Dashboard:
cd dashboard && npm install && npm run build→ served at/whendashboard/build/exists. The API/WS/health/MCP routes are registered before the"/"static mount because a root mount matches everything — keep that ordering. - DB migrations: Alembic (
db/migrations/), upgrade-on-boot viainit_db. Schema changes are migrations, never ad-hocCREATE.
Path-scoped rules in .claude/rules/ load automatically when you edit the files they cover. They carry the fine-grained "don't break this" detail; this file is the map.