Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ inspection.
|----------|---------|-------------|
| `SWML_BASIC_AUTH_USER` | `signalwire` | Basic auth username |
| `SWML_BASIC_AUTH_PASSWORD` | *auto-generated* | Basic auth password (32-char token if not set) |
| `SIGNALWIRE_SIGNING_KEY` | *unset* | Signing Key for **inbound** webhook signature validation. See [Webhook Signature Validation](#webhook-signature-validation). |
| `SIGNALWIRE_SWAIG_SECRET` | *random per process* | Secret used to sign this agent's **outbound** SWAIG function tokens. See [SWAIG Function Token Signing](#swaig-function-token-signing). |

### Security Headers and Policies

Expand Down Expand Up @@ -380,3 +382,48 @@ Before deploying to production:
- [ ] SSL certificate expiration monitoring in place
- [ ] `signing_key` (or `SIGNALWIRE_SIGNING_KEY` env) configured on every AgentBase
- [ ] Regular security updates applied

## SWAIG Function Token Signing

Each SWAIG function call an agent hands to the AI carries a short-lived token
that the agent signs and later verifies itself, so a function URL cannot be
replayed or called out of context.

By default that secret is **generated randomly per process**. Tokens therefore
stop verifying whenever the agent restarts, and every replica of the same agent
signs with a different key.

The failure is easy to miss and points away from its cause. A call placed
before a restart keeps running; its next tool call arrives carrying a token the
new process cannot verify; and the caller is told:

```
the security token for this function is invalid or expired
```

which reads as though the tool failed, rather than as though it was never
allowed to run. Nothing errors server-side and nothing logs a mismatch.

Set the secret explicitly whenever an agent restarts while calls are live —
which includes every rolling deploy — and always when more than one replica
serves the same agent:

<!-- snippet: no-run starts a blocking server (covered by SNIPPET-COMPILE) -->
```python
from signalwire import AgentBase

agent = AgentBase(
name="my-agent",
swaig_secret="a-long-random-string", # or set SIGNALWIRE_SWAIG_SECRET
)
agent.serve()
```

Resolution order is the constructor argument, then `SIGNALWIRE_SWAIG_SECRET`,
then a fresh random secret.

Treat it like any other signing secret: keep it out of source control, and use
the same value across every replica of one agent. It is unrelated to
`signing_key` / `SIGNALWIRE_SIGNING_KEY`, which validates **inbound** webhooks
and is issued by SignalWire; this one is the agent's own and never leaves the
process.
3 changes: 3 additions & 0 deletions signalwire/signalwire/ai_chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

from .gateway import ChatGateway, GatewayRejection
from .handoff import HandoffRouter, NonceEntry
from .client import (
AIChatClient,
AIChatError,
Expand All @@ -35,6 +36,8 @@
"ConversationInfo",
"ConversationNotFoundError",
"GatewayRejection",
"HandoffRouter",
"NonceEntry",
"RateLimitError",
"SummaryError",
]
44 changes: 44 additions & 0 deletions signalwire/signalwire/ai_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,19 @@
"""

import os
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any

import aiohttp

from signalwire.core.logging_config import get_logger
from signalwire.rest._base import _user_agent

logger = get_logger("ai_chat.client")

DEFAULT_PATH = "/api/ai/chat"

# The service streams keepalive whitespace ahead of slow responses (every
Expand Down Expand Up @@ -169,6 +173,45 @@ class ChatLog:
# ── Client ───────────────────────────────────────────────────────────


# Characters the chat service keeps in a conversation id. Anything else is
# stripped on arrival, silently and without error.
_ID_SAFE = re.compile(r"[^a-zA-Z0-9_\-.:]")


def _warn_if_id_will_be_altered(conversation_id: str) -> None:
"""Warn when the service will not store the id it is being given.

The service sanitizes conversation ids and drops disallowed characters
without reporting it, so a caller that composes ids -- ``root~2`` for a
second leg of ``root``, say -- gets back ``root2``, which is a DIFFERENT,
valid-looking id. Everything filed under the original is then unreachable,
with no error at any layer to indicate the id changed.

Two of the three characters worth avoiding come from this SDK itself:
``_`` and ``-`` occur inside ``secrets.token_urlsafe`` output, which is
what ``ChatGateway.mint_handle`` uses to generate ids, so a suffix built
from either cannot be told apart from the id it was appended to. ``:`` is
the gateway's own handle delimiter. That leaves ``.`` as the safe
separator for composing ids.
"""
if not isinstance(conversation_id, str) or not conversation_id:
return
cleaned = _ID_SAFE.sub("", conversation_id)
if cleaned != conversation_id:
removed = "".join(sorted({c for c in conversation_id if _ID_SAFE.match(c)}))
logger.warning(
"conversation_id_will_be_sanitized",
requested=conversation_id,
stored_as=cleaned,
removed_characters=removed,
message=(
"[signalwire] the chat service will store this conversation "
"under a different id; anything filed under the requested id "
"will not be found. Use '.' to compose ids."
),
)


class AIChatClient:
"""Async client for the SignalWire AI Chat service."""

Expand Down Expand Up @@ -340,6 +383,7 @@ async def create_conversation(
reinit: bool = False,
) -> ConversationInfo:
"""Create a conversation (or reinitialize an existing one)."""
_warn_if_id_will_be_altered(conversation_id)
params: dict[str, Any] = {"id": conversation_id, "config_url": config_url}
if user_message:
params["user_message"] = user_message
Expand Down
Loading