Files
hold-slayer/CLAUDE.md
Robert Helewka 5c178bb7bd
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
feat(auth): rate-limit the unauthenticated /auth/* edge
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>
2026-07-30 18:46:54 -04:00

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

  1. Fresh Environment Testcp .env.example .env, set DATABASE_URL and either CASDOOR_* + OWNER_NAME (SSO) or CASDOOR_ENABLED=false with HOST=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_error in main.py). It must never boot into a state where it silently can't place calls — that's why use_mock_sip is opt-in and an unconfigured trunk fails startup.
  2. 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.
  3. Observable & Debuggable/health is honest: it reports degraded with 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 typed EventBus; new call-lifecycle facts become typed events, not prints.
  4. 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.
  5. Actually Workspytest 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 blocks 911/9911/112 (and their E.164 forms, whitespace/dashes stripped) at gateway.make_call, before the concurrency check and before any SIP action. Every dialling path — REST make_call, MCP make_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 bypasses make_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 in gateway.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 ED2 dispatcher 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_event funnel) 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_bearer in auth.py turns either into a User, and is_owner/get_current_owner gate 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_config refuses 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.

  • /health tells the truth. healthy requires 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 via call_persistence. MCP/REST history and summaries read from the DB through session_scope(). Don't confuse the two — a call that ended is gone from active_calls and 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_call raises ToolError on 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 pjsua2 bindings 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 — /health reports engine: mock and refuses healthy.

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=json in core/logging_config.py, applied at import and again in lifespan because uvicorn installs its own handlers (propagate=False) after importing the app. The access log is included, with status_code as a number so Loki can range-filter it. Text remains the default; the Docker image sets json.
  • No /metrics endpoint and no Prometheus. Unlike the metrics-bearing estate services, there's no exposition endpoint here yet.
  • No health-probe access-log filter. Every /health poll 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, not X-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 to main. The compose stack requires SSO enabled (published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo.
  • A committed .DS_Store is not present (good), but do check git status stays clean of OS cruft; .gitignore already lists it.

Working here

  • Run: uvicorn main:app --host 0.0.0.0 --port 8000 (or python main.py). The CLI --port wins over settings.port in 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, py312 target).
  • Dashboard: cd dashboard && npm install && npm run build → served at / when dashboard/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 via init_db. Schema changes are migrations, never ad-hoc CREATE.

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.