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
@vorenus✅ vorenusStandard mention
user@vorenus.com❌Preceded by @ (email)
site.com/@vorenus❌Preceded 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 exceptions — CenturioNotFound, 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.