Skip to content

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:

python
def _caesar_filter(caesar_telegram_id: int) -> filters.BaseFilter:
    return filters.User(user_id=caesar_telegram_id) & filters.ChatType.PRIVATE
PropertyValue
CheckTelegram user ID match + private chat type
Applied toAll 12 handlers (commands + free text)
Failure modeSilent drop at framework level
Per-handler authNot needed — filter registered at handler level
Group messagesRejected 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:

ActionCommandPayload
remove_centurio/remove <name>{"name": "<centurio>"}
revoke_edictum/revoke <name>{"name": "<edictum>"}

TOTP Security Properties

PropertyValueImplementation
AlgorithmTOTP (RFC 6238)pyotp library
Time step30 secondspyotp default
Drift tolerance±30 seconds_DRIFT_STEPS = 1
Max attempts3_MAX_ATTEMPTS = 3 (hardcoded)
TTL120 seconds_TTL_SECONDS = 120 (hardcoded)
ComparisonTiming-safehmac.compare_digest via pyotp
Store key(chat_id, user_id)Prevents group-chat bypasses
OTP cleanupBest-effort deletionOTP messages removed from chat history
Fail closedYesMalformed secrets/codes return False
Replay preventionTTL + attempt limitAuto-cleanup on next get_pending()

TOTP Flow

Auctoritas Store

python
@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 = 0

The 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:

NamespaceRegexModuleRejects
Centurio names^[a-z][a-z0-9_-]*$centurio.pyUppercase, spaces, /, .., special chars
Memoria entries^[a-z0-9][a-z0-9_-]*$memoria/store.pySame, plus leading digits allowed

Reserved Names

The following centurio names are rejected to prevent identity confusion:

NameReason
caesarHuman operator identity
legatusOrchestrator identity
allBroadcast audience keyword
praetoriumMessage bus identity

Validated at two points (defense-in-depth):

  1. Centurio.__post_init__() — dataclass construction
  2. _create_centurio() — before any filesystem operation

Boundary 4: Filesystem Protection

Every file read, write, and delete operation in memoria/store.py calls _reject_symlink():

python
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():

python
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 path

The 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:

FunctionWhat it escapesUsed for
escape()&, <, >Content body
quoteattr()&, <, >, ", ' + wraps in quotesAttribute 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):

ProtectionImplementation
Default escapingHTMLRenderer(escape=True) escapes all text by default
Raw HTML blocksExplicitly escaped via html.escape() — never trusted
Inline HTMLExplicitly escaped — never passed through
Link URLsEscaped via html.escape() to prevent attribute injection
Blank linesCollapsed to prevent excessive whitespace

Error Messages

Error messages follow a strict boundary pattern:

LocationDetail LevelExample
Domain codeFull detail (for logging)"Centurio name contains invalid characters: 'a/../b'"
Telegram boundaryGeneric (user-facing)"❌ An error occurred"
Log outputFull stack tracelogger.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:

python
# SECURITY: negative lookbehind prevents matching @name inside emails/URLs
_MENTION_RE = re.compile(r"(?<![.\w/@])@(\w+)")
InputMatchesReason
@vorenusvorenusStandard mention
user@vorenus.comPreceded by @ (email)
site.com/@vorenusPreceded by / (URL path)
Hello @vorenus @brutus✅ bothMultiple 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:

ContextEscapingFunction
Centurio names in rosterquoteattr()_build_system_prompt()
Descriptions in rosterescape()_build_system_prompt()
Status valuesquoteattr()_build_status_xml()
History senderquoteattr()format_history_xml()
History textescape()format_history_xml()
Reply context senderquoteattr()extract_reply_context()
Reply context textescape()extract_reply_context()

Complete Defense Matrix

BoundaryThreatControlModule
Telegram entryUnauthorized accessUser ID + private chat filterbot.py
Destructive actionsUnauthorized deletionTOTP with timing-safe comparisontotp.py, auctoritas.py
Centurio namesIdentity collisionRegex ^[a-z][a-z0-9_-]*$ + reserved namescenturio.py
Memoria namesPath traversalRegex ^[a-z0-9][a-z0-9_-]*$memoria/store.py
File operationsSymlink attackspath.is_symlink() rejectionmemoria/store.py
Private notesCross-centurio accessClosure-scoped MCP servermemoria/tools.py
Private notesUnauthorized overwriteAppend-only (raises if exists)memoria/store.py
XML contentInjectionescape() + quoteattr() on all contextrendering.py, memoria/store.py
HTML outputXSS / injectionHTMLRenderer(escape=True) on all outputmarkdown_render.py
Raw HTML in LLM outputPass-throughEscaped, never passed throughmarkdown_render.py
Error messagesInformation disclosureGeneric at boundary, detailed in logstelegram/utils.py
OTP replayReplay attackTTL=120s, max 3 attempts, auto-cleanupauctoritas.py
@mention parsingEmail/URL false positivesNegative lookbehind regexlegatus.py
Reply contextTelegram user spoofingXML escaping on sender + texttelegram/utils.py

Error Hierarchy

All domain errors inherit from a single base class:

Error Handling Rules

  1. Use domain exceptionsCenturioNotFound, not KeyError
  2. Catch at boundaries — Telegram handler catches and converts to user message
  3. Propagate in domain — domain code raises, never catches
  4. Never swallow — all exceptions are logged at the boundary
  5. 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 AreaExamples
Name validationEmpty names, uppercase, path traversal (../), reserved names
Symlink rejectionSymlinked XML files, symlinked directories
XML escapingNames with <, >, ", & characters
TOTP verificationValid code, invalid code, expired TTL, max attempts, timing-safe
Auth filteringWrong user ID, group chat, missing user
Mention regexEmails, URLs, edge cases
Error propagationDomain exceptions caught at boundary, details not leaked
HTML escapingRaw HTML in LLM output, link injection

All security tests are required to pass — 100% coverage enforced via pytest --cov-fail-under=100.

Built with Roman discipline.