The claude-telegram-mirror bridges a local Claude Code CLI session to a remote Telegram chat. The system has five trust boundaries, shown below.
graph TB
subgraph "Untrusted Network"
TG["Telegram Bot API<br/>(HTTPS, external)"]
end
subgraph "Local Machine — Same User"
BOT["Bot Process<br/>(reqwest long-poll)"]
DAEMON["Bridge Daemon"]
SOCK["Unix Domain Socket<br/>(bridge.sock, 0o600)"]
HOOKS["Hook Handler<br/>(ctm hook subcommand)"]
TMUX["tmux Sessions<br/>(send-keys injection)"]
FS["File System<br/>(~/.config/claude-telegram-mirror/)"]
DB["SQLite DB<br/>(sessions.db, 0o600)"]
end
TG -- "HTTPS poll" --> BOT
BOT -- "in-process" --> DAEMON
DAEMON -- "listen/accept" --> SOCK
HOOKS -- "connect/send NDJSON" --> SOCK
DAEMON -- "Command::new" --> TMUX
DAEMON -- "read/write" --> FS
DAEMON -- "rusqlite" --> DB
HOOKS -- "stdin pipe" --> HOOKS
| Boundary | Trust level | Threat |
|---|---|---|
| Telegram Bot API to Bot process | Untrusted network | Spoofed updates, message injection from unauthorized chats |
| Unix domain socket (bridge.sock) | Same-user local IPC | Other local users or processes connecting |
| tmux send-keys | Same-user process control | Command injection via unsanitized text |
| File system (config dir) | Same-user file access | World-readable secrets, path traversal |
| Hook scripts (stdin) | Subprocess execution | Oversized payloads, malformed JSON |
File: rust-crates/ctm/src/injector.rs
All tmux commands use Command::new("tmux") with .arg() chains. The process
binary is the first argument and all subsequent arguments are passed directly to
the kernel without shell interpretation. The -l (literal) flag on send-keys
ensures tmux treats the injected string as literal keystrokes. No escaping or
quoting is needed.
Dead code for FIFO and PTY injection methods has been removed. The only
injection method is tmux. See ADR-004.
Slash commands (e.g., /clear, /rename) are validated against a character
whitelist ([a-zA-Z0-9_- /]) before injection. Commands containing shell
metacharacters are rejected.
Files: rust-crates/ctm/src/main.rs (subscriber layer), rust-crates/ctm/src/bot/mod.rs (regex)
A tracing subscriber layer (ScrubWriter in main.rs) applies scrub_bot_token()
to all log output. The regex bot\d+:[A-Za-z0-9_-]+/ matches the Telegram bot token
pattern in API URLs and replaces it with bot[REDACTED]/.
All log output goes to stderr via the tracing subscriber. There is no
file transport, so tokens cannot leak into log files on disk.
The error handler in rust-crates/ctm/src/bot/client.rs also scrubs bot
tokens from error messages before logging.
File: rust-crates/ctm/src/daemon/telegram_handlers.rs
A chat ID check verifies chat.id against the configured chat_id on
every incoming update. Updates from unauthorized chats are silently dropped
(logged as a warning, no reply) — by design, since replying would confirm the
bot's existence and function to an attacker (ADR-006 L4.6).
Approval callback handlers (approve:, reject:, abort:), answer
handlers (answer:, toggle:, submit:), all verify the chat ID
matches the configured chat_id before processing. This prevents
IDOR attacks where a user who knows an approval ID could respond from a
different chat.
File: rust-crates/ctm/src/daemon/socket_handlers.rs
Session IDs from hook events are validated before any database operation:
- Maximum length: 128 characters
- Character set:
[a-zA-Z0-9_.-]only - Empty/null values are rejected
Messages with invalid session IDs are dropped with a warning log.
File: rust-crates/ctm/src/socket.rs
The socket server enforces three limits:
- NDJSON line limit: 1 MiB (1,048,576 bytes) per line. Oversized lines are dropped and logged.
- Connection limit: 64 concurrent connections. New connections beyond this limit are destroyed immediately.
- Directory permissions: The socket directory is created with mode 0o700 (owner-only) and the socket file is set to 0o600 after binding.
A PID file lock prevents multiple daemon instances from racing on the same socket.
File: rust-crates/ctm/src/config.rs
validate_socket_path() rejects socket paths that:
- Contain
..(directory traversal) - Are not absolute (do not start with
/) - Exceed 104 characters (the AF_UNIX
sun_pathlimit)
Invalid paths fall back to the default socket path in the config directory.
File: rust-crates/ctm/src/config.rs
ensure_config_dir() creates ~/.config/claude-telegram-mirror/ with mode
0o700. If the directory already exists, it enforces 0o700 via
fs::set_permissions(). All config directory creation goes through this
single function.
File: rust-crates/ctm/src/session.rs
Immediately after opening the SQLite database, fs::set_permissions(db_path, 0o600)
is called to ensure the database file is owner-readable/writable only.
File: rust-crates/ctm/src/hook.rs
The hook handler reads stdin in chunks and enforces a 1 MiB (1,048,576 byte) limit. If the accumulated input exceeds this limit, the handler logs a warning and exits cleanly without processing the payload.
File: rust-crates/ctm/src/daemon/files.rs
Downloaded files from Telegram are handled with several protections:
- The downloads directory is created with mode 0o700
- Downloaded files are written with mode 0o600
- Filenames are sanitized: path separators replaced,
..removed, dotfile prefixes neutralized, length capped at 200 characters - Every filename is prefixed with a UUID for uniqueness
- Files older than 24 hours are automatically cleaned up
- Telegram enforces a 20 MB file size limit at the API level
| File/Directory | Mode | Rationale |
|---|---|---|
~/.config/claude-telegram-mirror/ |
0o700 | Contains bot token in config, session database, socket |
~/.telegram-env |
0o600 | Contains bot token and chat ID for shell sourcing |
config.json |
0o600 | Contains bot token |
sessions.db |
0o600 | Contains session metadata, approval records |
bridge.sock |
0o600 | IPC socket, same-user access only |
bridge.pid |
default | PID lock file, no sensitive content |
downloads/ |
0o700 | User-uploaded files from Telegram |
| Downloaded files | 0o600 | User-uploaded content, owner-only |
| Boundary | Validation | Enforcement |
|---|---|---|
| Socket messages: session ID | 128 char max, [a-zA-Z0-9_.-] |
socket_handlers.rs — is_valid_session_id() |
| Socket lines | 1 MiB max per NDJSON line | socket.rs — MAX_LINE_BYTES |
| Socket connections | 64 concurrent max | socket.rs — MAX_CONNECTIONS |
| Hook stdin | 1 MiB max | hook.rs — MAX_STDIN_BYTES |
| Socket paths | No .., absolute only, 104 char max |
config.rs — validate_socket_path() |
| Slash commands | Character whitelist: [a-zA-Z0-9_- /] |
injector.rs — is_valid_slash_command() |
| Download filenames | Sanitized, UUID-prefixed, no .., 200 char max |
telegram_handlers.rs — sanitize_filename() |
| Download file size | 20 MB max | Telegram Bot API server-side limit |
| Telegram chat ID | Exact match against configured chat_id |
telegram_handlers.rs — chat ID check |
Before modifying security-sensitive code, verify:
- No shell interpolation. All subprocess calls use
Command::new()with.arg()chains, never string concatenation into a shell command. - No hardcoded secrets. Bot tokens come from environment variables or the config file, never from source code.
- File permissions enforced. Any new file or directory in the config directory uses 0o600 (files) or 0o700 (directories).
- Input validated at boundary. Any data arriving from the socket, stdin, or Telegram is validated before use. Session IDs, file paths, and command strings are checked against their respective whitelists.
- Bot token not logged. Any error message that might contain a URL is
passed through
scrub_bot_token()before logging. - Chat ID checked. Any new callback handler or message handler verifies
the chat ID matches the configured
chat_id. - Tests pass. Run
cargo testand confirm no regressions. - No new file logging. All log output goes to stderr. Do not add file transports to the logger.
- Path traversal blocked. Any user-controlled path component is
validated to reject
..and non-absolute paths.
If you discover a security vulnerability in claude-telegram-mirror, please report it responsibly:
- Do not open a public GitHub issue for security vulnerabilities.
- Email the maintainers at the address listed in
package.json, or use GitHub's private vulnerability reporting feature on the repository. - Include a description of the vulnerability, steps to reproduce, and the potential impact.
- Allow up to 90 days for a fix before public disclosure.
We will acknowledge receipt within 48 hours and aim to release a fix within 30 days of confirmation.