Security Model
Security in Legio is not a layer — it's woven into every boundary. Every defensive measure has a # SECURITY: prefixed inline comment in source code explaining the attack it prevents.
Boundary Map
Boundary Details
Boundary 1: Telegram Authentication
Every incoming Telegram update passes through _caesar_filter:
def _caesar_filter(caesar_telegram_id: int) -> filters.BaseFilter:
return filters.User(user_id=caesar_telegram_id) & filters.ChatType.PRIVATE| Property | Value |
|---|---|
| Check | Telegram user ID match + private chat type |
| Applied to | All 12 handlers (commands + free text) |
| Failure mode | Silent drop at framework level |
| Per-handler auth | Not needed — filter registered at handler level |
| Group messages | Rejected by ChatType.PRIVATE |
All handlers are registered with this composite filter. Non-Caesar messages never reach application code.
Boundary 2: TOTP Authorization
Destructive actions require TOTP verification before execution:
| Action | Command | Payload |
|---|---|---|
remove_centurio | /remove <name> | {"name": "<centurio>"} |
revoke_edictum | /revoke <name> | {"name": "<edictum>"} |
TOTP Security Properties
| Property | Value | Implementation |
|---|---|---|
| Algorithm | TOTP (RFC 6238) | pyotp library |
| Time step | 30 seconds | pyotp default |
| Drift tolerance | ±30 seconds | _DRIFT_STEPS = 1 |
| Max attempts | 3 | _MAX_ATTEMPTS = 3 (hardcoded) |
| TTL | 120 seconds | _TTL_SECONDS = 120 (hardcoded) |
| Comparison | Timing-safe | hmac.compare_digest via pyotp |
| Store key | (chat_id, user_id) | Prevents group-chat bypasses |
| OTP cleanup | Best-effort deletion | OTP messages removed from chat history |
| Fail closed | Yes | Malformed secrets/codes return False |
| Replay prevention | TTL + attempt limit | Auto-cleanup on next get_pending() |
TOTP Flow
Auctoritas Store
@dataclass
class Auctoritas:
action: str # e.g. "remove_centurio"
payload: dict # e.g. {"name": "vorenus"}
chat_id: int
user_id: int
message_id: int # For cleanup
created_at: float # time.monotonic()
attempts: int = 0The store is keyed by (chat_id, user_id) — only one pending action per user per chat. New requests overwrite stale ones. Expired entries are cleaned up on read via get_pending().
Boundary 3: Name Validation
Two separate regex patterns protect different namespaces:
| Namespace | Regex | Module | Rejects |
|---|---|---|---|
| Centurio names | ^[a-z][a-z0-9_-]*$ | centurio.py | Uppercase, spaces, /, .., special chars |
| Memoria entries | ^[a-z0-9][a-z0-9_-]*$ | memoria/store.py | Same, plus leading digits allowed |
Reserved Names
The following centurio names are rejected to prevent identity confusion:
| Name | Reason |
|---|---|
caesar | Human operator identity |
legatus | Orchestrator identity |
all | Broadcast audience keyword |
praetorium | Message bus identity |
Validated at two points (defense-in-depth):
Centurio.__post_init__()— dataclass construction_create_centurio()— before any filesystem operation
Boundary 4: Filesystem Protection
Symlink Rejection
Every file read, write, and delete operation in memoria/store.py calls _reject_symlink():
def _reject_symlink(path: Path) -> None:
# SECURITY: prevent symlink-follow reads/writes/deletes
if path.is_symlink():
raise MemoriaError("Symlink not allowed")This prevents an attacker who can create symlinks in the castra directory from redirecting file operations to arbitrary paths.
Path Construction
Paths are constructed safely via _safe_path():
def _safe_path(base_dir: Path, name: str) -> Path:
_validate_name(name) # Regex check
path = base_dir / f"{name}.xml"
_reject_symlink(path) # Symlink check
return pathThe combination of name validation + symlink rejection ensures:
- No path traversal via
../(blocked by regex) - No symlink following to external paths (blocked by
is_symlink()) - No directory confusion via special characters (blocked by regex)
XML Injection Prevention
All XML content is escaped before writing:
| Function | What it escapes | Used for |
|---|---|---|
escape() | &, <, > | Content body |
quoteattr() | &, <, >, ", ' + wraps in quotes | Attribute values |
Applied in both _wrap_xml() (edicta, acta) and _wrap_commentarium_xml() (commentarii).
Boundary 5: Output Protection
Markdown Rendering
The Telegram markdown renderer (markdown_render.py) is built on HTMLRenderer(escape=True):
| Protection | Implementation |
|---|---|
| Default escaping | HTMLRenderer(escape=True) escapes all text by default |
| Raw HTML blocks | Explicitly escaped via html.escape() — never trusted |
| Inline HTML | Explicitly escaped — never passed through |
| Link URLs | Escaped via html.escape() to prevent attribute injection |
| Blank lines | Collapsed to prevent excessive whitespace |
Error Messages
Error messages follow a strict boundary pattern:
| Location | Detail Level | Example |
|---|---|---|
| Domain code | Full detail (for logging) | "Centurio name contains invalid characters: 'a/../b'" |
| Telegram boundary | Generic (user-facing) | "❌ An error occurred" |
| Log output | Full stack trace | logger.exception() |
Internal details (file paths, SQL errors, stack traces) are never exposed to the Telegram user.
Boundary 6: Mention Parsing
The @mention regex prevents matching inside emails and URLs:
# SECURITY: negative lookbehind prevents matching @name inside emails/URLs
_MENTION_RE = re.compile(r"(?<![.\w/@])@(\w+)")| Input | Matches | Reason |
|---|---|---|
@vorenus | ✅ vorenus | Standard mention |
user@vorenus.com | ❌ | Preceded by @ (email) |
site.com/@vorenus | ❌ | Preceded by / (URL path) |
Hello @vorenus @brutus | ✅ both | Multiple mentions |
Only mentions matching registered centuriones (case-insensitive) are returned.
Boundary 7: Context Injection
XML context blocks (roster, status, history) are escaped to prevent prompt injection:
| Context | Escaping | Function |
|---|---|---|
| Centurio names in roster | quoteattr() | _build_system_prompt() |
| Descriptions in roster | escape() | _build_system_prompt() |
| Status values | quoteattr() | _build_status_xml() |
| History sender | quoteattr() | format_history_xml() |
| History text | escape() | format_history_xml() |
| Reply context sender | quoteattr() | extract_reply_context() |
| Reply context text | escape() | extract_reply_context() |
Complete Defense Matrix
| Boundary | Threat | Control | Module |
|---|---|---|---|
| Telegram entry | Unauthorized access | User ID + private chat filter | bot.py |
| Destructive actions | Unauthorized deletion | TOTP with timing-safe comparison | totp.py, auctoritas.py |
| Centurio names | Identity collision | Regex ^[a-z][a-z0-9_-]*$ + reserved names | centurio.py |
| Memoria names | Path traversal | Regex ^[a-z0-9][a-z0-9_-]*$ | memoria/store.py |
| File operations | Symlink attacks | path.is_symlink() rejection | memoria/store.py |
| Private notes | Cross-centurio access | Closure-scoped MCP server | memoria/tools.py |
| Private notes | Unauthorized overwrite | Append-only (raises if exists) | memoria/store.py |
| XML content | Injection | escape() + quoteattr() on all context | rendering.py, memoria/store.py |
| HTML output | XSS / injection | HTMLRenderer(escape=True) on all output | markdown_render.py |
| Raw HTML in LLM output | Pass-through | Escaped, never passed through | markdown_render.py |
| Error messages | Information disclosure | Generic at boundary, detailed in logs | telegram/utils.py |
| OTP replay | Replay attack | TTL=120s, max 3 attempts, auto-cleanup | auctoritas.py |
| @mention parsing | Email/URL false positives | Negative lookbehind regex | legatus.py |
| Reply context | Telegram user spoofing | XML escaping on sender + text | telegram/utils.py |
Error Hierarchy
All domain errors inherit from a single base class:
Error Handling Rules
- Use domain exceptions —
CenturioNotFound, notKeyError - Catch at boundaries — Telegram handler catches and converts to user message
- Propagate in domain — domain code raises, never catches
- Never swallow — all exceptions are logged at the boundary
- Never leak — error messages at Telegram boundary are generic (
"❌ An error occurred")
Test Coverage
Security tests are marked with @pytest.mark.security and run on every commit:
| Test Area | Examples |
|---|---|
| Name validation | Empty names, uppercase, path traversal (../), reserved names |
| Symlink rejection | Symlinked XML files, symlinked directories |
| XML escaping | Names with <, >, ", & characters |
| TOTP verification | Valid code, invalid code, expired TTL, max attempts, timing-safe |
| Auth filtering | Wrong user ID, group chat, missing user |
| Mention regex | Emails, URLs, edge cases |
| Error propagation | Domain exceptions caught at boundary, details not leaked |
| HTML escaping | Raw HTML in LLM output, link injection |
All security tests are required to pass — 100% coverage enforced via pytest --cov-fail-under=100.