Security model
Threat model, cryptographic invariants, Communication Agent controls, and known limitations for this deployment.
Reporting vulnerabilities
Email [email protected]. Please do not open public GitHub issues for unpatched vulnerabilities. Acknowledgement target: 72 hours. Fixes are disclosed in the CHANGELOG once a release ships.
End-user support
For product help (setup, disconnect, account questions): email [email protected]. Do not use the security address for general support.
Trust boundaries
| Boundary | Trusted credential / state | Lives |
|---|---|---|
Inbound HTTP (Claude / ChatGPT / MCP clients → /mcp) |
OAuth JWT issued and verified by this server; legacy mode may use api.mctl.ai |
Bearer header, per request |
Outbound MTProto (gotd/td → Telegram) |
Encrypted per-user session blob | telegram_accounts.session_encrypted |
| Communication Agent listener and queue | Per-user listener profile, encrypted message/event rows, queue jobs, conversation and policy state | User-scoped Postgres agent tables; active only for listener-enabled profiles |
Every database lookup for user content is intended to be scoped to the owning user. Compromise of one account should not expose another account unless the service, key derivation, or deployment boundary itself is compromised.
What the server sees
- Plaintext MTProto traffic and tool content in memory. The service must decrypt the session and message/action fields while processing them.
- Encrypted Communication Agent content at rest. When the listener is enabled, relevant message bodies and proposed action payloads are durably stored for queue recovery, policy evaluation, approval, and conversation context.
- Audit metadata, persisted separately. Tool/action name, redacted peer reference, status, and redacted error text. Audit rows do not contain Telegram message bodies.
Communication Agent safety boundary
The model prompt and model output are not trusted as a security boundary. The server-side listener, state machine, policy engine, approval flow, and Telegram send gate remain authoritative.
- Explicit enablement. Only profiles with the listener enabled receive durable Telegram updates. Disabling a profile removes the listener account and immediately stops its pinned Telegram client.
- Crash-safe ingestion. Incoming event, queue job, and conversation activity timestamp are committed in one transaction. Database failures are returned to Telegram's update manager so the update watermark is not acknowledged and gap recovery can retry.
- Saved Messages privacy. Only an explicit first-token
/mctlcommand is stored and routed. Ordinary notes in Saved Messages are ignored. - Human takeover. A genuine owner reply, including a media-only reply, moves the conversation to
taken_over. Programmatic text/media sends are correlated by their Telegram message ID so their echo is not mistaken for a human reply. The state does not expire on its own; the conversation staystaken_overuntil an explicit/mctl continue <conversation id>command resets it toactive. - Fail-closed policy. Unknown modes, states, and action types are denied. The global kill switch, off/paused profiles, blocked senders, peer mismatch, missing disclosure, unsafe links/credentials/phone-like content, and invalid structural limits deny an action.
- Approval modes.
observemode requires owner approval for replies.guardedmode can allow only allowlisted replies that satisfy every server-side policy, turn budget, and rate constraint; other actions require approval or are denied. - No blind retry after a possible send. The action lifecycle uses compare-and-set transitions. If a process crashes after entering
executing, that action is not automatically retried, avoiding duplicate messages.
Enabling the listener does not bypass the existing ALLOW_SEND, scope, per-account, and per-peer send gates.
Communication Agent storage and retention
incoming_eventsandconversation_messagesmay contain third-party Telegram message bodies.agent_actionsmay contain proposed reply payloads and approval/policy metadata.- Message bodies and action payloads are AES-256-GCM sealed with the owner's derived key before database storage.
- A daily sweeper deletes stored agent message content older than
AGENT_RETENTION_DAYS; default is 30 days. Setting it to0keeps rows indefinitely. - Audit retention is separate:
AUDIT_RETENTION_DAYSdefaults to 90 days and audit rows contain no message body.
Telegram account risk
MCTL Assistant is an unofficial third-party connector using the Telegram API. Telegram's anti-spam system can limit an account (PEER_FLOOD) or freeze it (FROZEN_METHOD_INVALID) when it sends spam-like traffic, whichever app the traffic comes from. On the hosted deployment every session reaches Telegram from the same datacenter under one app id, which gives that traffic a shared profile.
- The server does not retry a frozen account's refusals. The call fails with a clear error and is not repeated.
- A frozen account can be appealed in the Telegram app, through @SpamBot, or at
[email protected]. Nothing on this server lifts a freeze. - Local Bridge keeps the traffic on your machine. In Local Bridge mode the MTProto connection leaves from your own device and IP address rather than the shared datacenter.
Local Bridge mode
When an account is set to mode='local', the MTProto session it uses lives on the user's machine rather than in the hosted session pool, and this server acts as a relay. Turning it on for a new account is self-service via the mctl-telegram-local activate CLI; migrating an existing hosted account into local mode still goes through an operator (set_account_mode). Setup is documented at /docs/local-bridge.
- Whether the server ever held a copy of the session depends on how the account became local.
session_encryptedis nullable: an account provisioned directly as local-only (viaprovision_local_account, without ever completing a hosted login) hassession_encrypted = NULLfrom the start — the server never holds a copy of your session at all. An account migrated from an existing hosted login (viaset_account_mode) keeps the sealed session blob that was stored when it first connected; switching to local mode stops the server from borrowing that blob, but does not erase it (clearing it is a separate, not-yet-implemented step). Revoking the hosted session of a migrated account does not revert it to hosted mode or disconnect the bridge —modeis a property of the account, independent of whether its stored session is still considered valid. To render a migrated account's stored copy useless, terminate that session from your own Telegram client (Settings → Devices); the stored bytes then hold a dead authorization key. - New Telegram traffic originates from your machine. Once a daemon is connected, tool calls are dispatched over the relay to it and the MTProto connection to Telegram is made by your daemon using the session on your disk. The
call_pathaudit column records which route each call took. - Tool arguments and responses still pass through relay memory. The relay can see plaintext while routing the call and does not provide end-to-end encryption from the MCP client to the daemon.
- Audit content remains metadata-only. The
call_pathmarks local relay calls, while message bodies are excluded from audit rows and stdout. - Communication Agent limitation. A hosted listener requires an active server-side Telegram client. Do not assume Local Bridge automatically provides the same always-on listener semantics without an explicitly deployed local listener path.
What logs and audit rows never persist
- Telegram message bodies or proposed reply payloads
- Phone numbers, login codes, or 2FA passwords
- Raw MTProto session bytes
- OAuth signing secrets, encryption keys, or derived per-user keys
Authorization: Bearerheader contents
This guarantee applies to logs and audit rows. Communication Agent message/action tables intentionally store encrypted content as described above.
Known coupling: shared-hmac-legacy mode
AUTH_MODE=shared-hmac-legacy validates JWTs using secret material shared with an external authorization service. It must also be unlocked explicitly with AUTH_ALLOW_SHARED_HMAC_LEGACY=true. The coupling does not apply to local-jwt, the mode tg.mctl.ai runs.
- Impact: compromise of either service's shared secret can affect both trust domains.
- Mitigation: rotate the shared secret for both services in the same change and prefer
local-jwtfor new deployments.
Cryptographic invariants
ENCRYPTION_KEYmust be 32 random bytes, hex-encoded as 64 characters. Production must set it.- MTProto session blobs, agent message bodies, and action payloads are AES-256-GCM sealed with per-user HKDF-derived keys.
- Legacy single-key session blobs are migrated to the per-user format when read.
- The deployment operator or an attacker controlling the running pod and key material can decrypt user data. Hosted mode is not zero-knowledge.
Send gate (defense in depth)
Every newly connected account starts read-only (send_enabled=false) unless its owner chooses Read + send on the permissions step while connecting. Every write to Telegram — send_message, send_media, edit_message, delete_messages, forward_messages, set_reaction, pin_message and send_reply — requires all of:
- Server flag
ALLOW_SEND=true. This is the operator kill switch: on for productiontg.mctl.ai; setALLOW_SEND=falseon a deployment that must never deliver. - Identity has the tool's scope (
telegram:messages:send, ortelegram:messages:pinfor pins). A tool outside the token's scopes, or outside the connection's tool profile, is not listed and is refused as unknown. telegram_accounts.send_enabled = truefor that account (owner sets this on the permissions step, viaset_send_consentor /telegram/connect/manage).- The per-peer send rate limit is not exhausted.
When any condition fails, no write RPC reaches Telegram. send_message and send_media return a dry-run result (sent=false) with a dry_reason; every other write tool — edit_message, delete_messages, forward_messages, set_reaction, pin_message and send_reply — returns a tool error naming the condition, never a preview. pin_message and send_reply additionally require a single-use confirmation issued by prepare_pin_message or draft_reply. A denied media call does not fetch its URL or decode its base64 payload.
Authentication-required mode
AUTH_REQUIRED=falseis for local development only. Production must use authenticated OAuth mode.AUTH_MODE=local-devreturns a fixed platform-admin identity and must not be reachable from a public production interface.
Rate limiting
The default per-identity token bucket is 30 requests/minute with the same burst. Write paths additionally use per-peer limits so one recipient cannot be targeted up to the global ceiling. Public and well-known endpoints are limited at the ingress layer.
Session TTL
- Idle TTL — 30 days. A session not used within the idle window is revoked.
- Absolute TTL — 90 days. A session past
expires_atis revoked. - An hourly sweeper applies the same predicate to abandoned rows.
Tamper-evident audit log
Audit rows form a SHA-256 hash chain using prev_hash and entry_hash. You can ask the server to verify the chain:
GET /api/account/audit/verify
{
"ok": true,
"verified": 42
}
A non-OK response includes first_bad_id and a reason. Rows created before hash chaining are skipped.
Prompt-injection content boundary
Read tools wrap Telegram message bodies in <telegram-content origin="telegram" peer="redacted" untrusted="true">…</telegram-content> and escape literal closing tags inside message text. This is a signalling boundary for the MCP client, not a substitute for the server-side Communication Agent policy engine.