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

250 lines
13 KiB
Markdown

# 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](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](.claude/rules/concurrency-threads.md) — 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 Test**`cp .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](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 `print`s.
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 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](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](.claude/rules/call-safety.md).
- **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](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](.claude/rules/auth-surfaces.md).
- **`/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](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](.env.example) and the
README's config table. See [the config rule](.claude/rules/config-startup.md).
**`.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](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](.claude/rules/mcp-tools.md).
---
## 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](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](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/](.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.