Skip to content

Commit 2f43122

Browse files
author
John O'Hare
committed
feat: Runtime mirroring toggle with /tg slash command and status bar
Add `ctm toggle` subcommand that flips mirroring on/off via a shared status.json file. The bridge daemon gates all Telegram sends through an AtomicBool, and the hook fast-path skips socket connection entirely when disabled (<1ms). A Command message type lets the CLI notify the running daemon to flip state in real time. - config.rs: MirrorStatus struct, read/write helpers with 0o600 perms - main.rs: Toggle + Stop subcommands, status now shows mirroring state - bridge.rs: mirroring_enabled AtomicBool, Command handler, PID in status - hook.rs: early return when status.json says disabled - .claude/commands/tg.md: /tg slash command triggers ctm toggle - statusline.cjs: TG ON/OFF indicator in header (green/red) - docs: updated for image/file transfer and hook format changes Co-Authored-By: DreamLabAI <github@thedreamlab.uk>
1 parent d0e9fe1 commit 2f43122

10 files changed

Lines changed: 250 additions & 7 deletions

File tree

.claude/commands/tg.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
description: Toggle Telegram mirroring on/off
3+
allowed-tools: Bash(ctm *)
4+
---
5+
Run `ctm toggle` and report the result to the user.

docs/ARCHITECTURE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ sequenceDiagram
120120
Bridge->>Bot: Send human-readable summary + Details button
121121
else TurnComplete
122122
Bridge->>Bridge: Check compaction state
123+
else SendImage
124+
Bridge->>Bridge: Validate file path
125+
Bridge->>Bot: send_photo or send_document
126+
Bot->>TG: sendPhoto/sendDocument (thread_id)
123127
end
124128
125129
Bot->>TG: sendMessage (thread_id)
@@ -148,6 +152,12 @@ sequenceDiagram
148152
Bridge->>INJ: inject(text)
149153
INJ->>TMUX: send-keys -l "text"
150154
INJ->>TMUX: send-keys Enter
155+
else Photo / Document
156+
Bridge->>Bridge: Download file via Bot API
157+
Bridge->>Bridge: Save to /tmp/ctm-images/{uuid}.{ext}
158+
Bridge->>Bridge: Set perms 0o600
159+
Bridge->>INJ: inject("[Image/File from Telegram: path]")
160+
INJ->>TMUX: send-keys -l notification
151161
else "stop" / "esc"
152162
Bridge->>INJ: send_key("Escape")
153163
INJ->>TMUX: send-keys Escape
@@ -268,6 +278,7 @@ graph LR
268278
| `error` | CLI -> TG | Error notification |
269279
| `turn_complete` | CLI -> TG | Claude finished a turn |
270280
| `pre_compact` | CLI -> TG | Context compaction starting |
281+
| `send_image` | Socket -> TG | Send image/file to Telegram |
271282

272283
## Security Architecture
273284

docs/DEVELOPMENT.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ cargo test session::tests
6969
|--------|-------|---------------|
7070
| `session.rs` | 3 | CRUD, lifecycle, approvals |
7171
| `socket.rs` | 2 | Server lifecycle, flock double-start prevention |
72-
| `formatting.rs` | 11 | ANSI stripping, truncation, path shortening, tool action summaries (bash/cargo/git, file ops, search, task, unknown), tool result summaries (success, error) |
72+
| `formatting.rs` | 14 | ANSI stripping, truncation, path shortening, tool action summaries (bash/cargo/git/chained, file ops, search, task, unknown), tool result summaries (success, error), message chunking, meaningful command extraction |
7373
| `injector.rs` | 2 | Key whitelist validation, no-target safety |
7474
| `config.rs` | 2 | Environment loading, config file defaults |
7575

@@ -173,6 +173,8 @@ Key patterns:
173173
- Tool input cache auto-expires after 5 minutes
174174
- Topic deletion is delayed and cancellable
175175
- Tool actions are summarized in natural language via `summarizer.rs` (rule-based, with optional LLM fallback)
176+
- Inbound photos/documents from Telegram are downloaded, saved securely, and injected as file paths into tmux
177+
- Outbound images/files sent via `send_image` socket messages are dispatched as photos or documents to Telegram
176178

177179
### summarizer.rs - Human-Readable Tool Summaries
178180

docs/PRD.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ The TypeScript implementation has 3 CRITICAL, 4 HIGH, and 3 MEDIUM security vuln
4545
- All file operations via `OpenOptions::mode()`
4646
- Zero `unsafe` blocks
4747
- No shell interpolation anywhere
48+
- **Bidirectional image/file transfer** — photos/documents from Telegram downloaded and injected into Claude; images/files sent to Telegram via bridge socket
49+
- **Human-readable tool summaries** — rule-based with optional LLM fallback
50+
- **Stale topic auto-cleanup** — dead sessions and their forum topics cleaned up automatically
4851

4952
## Non-Functional Requirements
5053

docs/SECURITY.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ graph TB
4848
| JSON parsing | Denial of service | `serde_json` Result handling, no panics |
4949
| PID file | Race condition | `flock(2)` atomic locking |
5050
| Rate limiting | API abuse | `governor` token-bucket per bot |
51+
| File downloads | Path traversal, symlinks | UUID filenames, 0o600 perms, absolute path validation |
5152

5253
## Vulnerability Fixes
5354

@@ -210,6 +211,27 @@ match serde_json::from_str::<BridgeMessage>(&line) {
210211
}
211212
```
212213

214+
## File Transfer Security
215+
216+
### Inbound (Telegram -> local disk)
217+
218+
Photos and documents received from Telegram are downloaded securely:
219+
220+
- **Download directory**: `/tmp/ctm-images/` created with `0o700` permissions
221+
- **UUID filenames**: Files are saved as `{uuid}.{ext}` (photos) or `{uuid}_{sanitized_name}` (documents), preventing predictable paths
222+
- **Atomic writes**: Files are downloaded to `{uuid}.downloading` then renamed to final path, preventing partial reads
223+
- **File permissions**: All downloaded files set to `0o600` (owner-only)
224+
- **Filename sanitization**: Original document filenames have `/` and `\` replaced with `_` to prevent path traversal
225+
226+
### Outbound (local disk -> Telegram)
227+
228+
The `send_image` socket message type validates file paths before sending:
229+
230+
- **Absolute path required**: Relative paths are rejected
231+
- **No path traversal**: Paths containing `..` are rejected
232+
- **Existence check**: File must exist before sending
233+
- **Extension-based routing**: Image extensions (jpg, png, gif, webp, bmp) sent as photos; all others sent as documents
234+
213235
## tmux Key Whitelist
214236

215237
Only these keys can be sent to tmux via the `send_key()` method:
@@ -253,11 +275,17 @@ graph TB
253275
LOG[supervisor.log]
254276
end
255277
278+
subgraph "Download Dir: 0o700"
279+
DLDIR[/tmp/ctm-images/]
280+
DLFILES["{uuid}.{ext} — 0o600"]
281+
end
282+
256283
DIR --> CONFIG
257284
DIR --> DB
258285
DIR --> SOCK
259286
DIR --> PID
260287
DIR --> LOG
288+
DLDIR --> DLFILES
261289
262290
style DIR fill:#ffa,stroke:#333
263291
style CONFIG fill:#afa,stroke:#333
@@ -278,3 +306,5 @@ graph TB
278306
- [x] tmux keys restricted to whitelist
279307
- [x] NDJSON parsing returns Result
280308
- [x] No secrets in logs or error messages
309+
- [x] File downloads use UUID filenames and 0o600 perms
310+
- [x] Outbound file paths validated (absolute, no traversal, exists)

docs/SETUP.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,13 @@ Add to `~/.claude/settings.json`:
219219
```json
220220
{
221221
"hooks": {
222-
"PreToolUse": [{ "command": "ctm hook" }],
223-
"PostToolUse": [{ "command": "ctm hook" }],
224-
"Notification": [{ "command": "ctm hook" }],
225-
"Stop": [{ "command": "ctm hook" }],
226-
"UserPromptSubmit": [{ "command": "ctm hook" }]
222+
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
223+
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
224+
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
225+
"SessionStart": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
226+
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
227+
"Notification": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }],
228+
"Stop": [{ "hooks": [{ "type": "command", "command": "ctm hook", "timeout": 5000 }] }]
227229
}
228230
}
229231
```
@@ -238,6 +240,8 @@ graph LR
238240
NOTIF[Notification]
239241
STOP[Stop]
240242
USER[UserPromptSubmit]
243+
SSTART[SessionStart]
244+
SEND[SessionEnd]
241245
end
242246
243247
subgraph "CTM Handler"
@@ -253,6 +257,8 @@ graph LR
253257
NOTIF -->|stdin| HOOK
254258
STOP -->|stdin| HOOK
255259
USER -->|stdin| HOOK
260+
SSTART -->|stdin| HOOK
261+
SEND -->|stdin| HOOK
256262
257263
HOOK -->|NDJSON| SOCKET
258264
HOOK -->|stdout passthrough| PRE

src/bridge.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::summarizer::LlmSummarizer;
99
use crate::types::{BridgeMessage, InlineButton, MessageType, SendOptions, SessionStatus};
1010
use std::collections::{HashMap, HashSet};
1111
use std::os::unix::fs::PermissionsExt;
12+
use std::sync::atomic::{AtomicBool, Ordering};
1213
use std::sync::Arc;
1314
use tokio::sync::{broadcast, Mutex, RwLock};
1415
use tokio::time::{self, Duration};
@@ -28,6 +29,8 @@ pub struct Bridge {
2829
summarizer: Arc<LlmSummarizer>,
2930
/// Sessions that have already been renamed with a task description
3031
named_sessions: Arc<RwLock<HashSet<String>>>,
32+
/// Runtime toggle for mirroring (read from status.json, flipped by `ctm toggle`)
33+
mirroring_enabled: Arc<AtomicBool>,
3134
}
3235

3336
struct CachedToolInput {
@@ -44,6 +47,10 @@ impl Bridge {
4447
let summarizer =
4548
LlmSummarizer::new(config.llm_summarize_url.clone(), config.llm_api_key.clone());
4649

50+
let mirroring_enabled = Arc::new(AtomicBool::new(crate::config::read_mirror_status(
51+
&config.config_dir,
52+
)));
53+
4754
Ok(Self {
4855
config,
4956
bot,
@@ -57,6 +64,7 @@ impl Bridge {
5764
pending_deletions: Arc::new(RwLock::new(HashMap::new())),
5865
summarizer: Arc::new(summarizer),
5966
named_sessions: Arc::new(RwLock::new(HashSet::new())),
67+
mirroring_enabled,
6068
})
6169
}
6270

@@ -81,6 +89,13 @@ impl Bridge {
8189
}
8290
}
8391

92+
// Write status file with our PID
93+
crate::config::write_mirror_status(
94+
&self.config.config_dir,
95+
self.mirroring_enabled.load(Ordering::Relaxed),
96+
Some(std::process::id()),
97+
);
98+
8499
// Send startup notification
85100
let _ = self
86101
.bot
@@ -158,6 +173,7 @@ impl Bridge {
158173
pending_deletions: self.pending_deletions.clone(),
159174
summarizer: self.summarizer.clone(),
160175
named_sessions: self.named_sessions.clone(),
176+
mirroring_enabled: self.mirroring_enabled.clone(),
161177
}
162178
}
163179
}
@@ -177,6 +193,7 @@ struct BridgeShared {
177193
pending_deletions: Arc<RwLock<HashMap<String, tokio::task::JoinHandle<()>>>>,
178194
summarizer: Arc<LlmSummarizer>,
179195
named_sessions: Arc<RwLock<HashSet<String>>>,
196+
mirroring_enabled: Arc<AtomicBool>,
180197
}
181198

182199
impl BridgeShared {
@@ -215,6 +232,18 @@ impl BridgeShared {
215232
// Auto-update tmux target if changed
216233
self.check_and_update_tmux_target(&msg).await;
217234

235+
// Handle system commands (toggle/enable/disable) regardless of mirroring state
236+
if msg.msg_type == MessageType::Command {
237+
self.handle_command(&msg).await?;
238+
return Ok(());
239+
}
240+
241+
// Gate: skip all Telegram sends when mirroring is disabled
242+
if !self.mirroring_enabled.load(Ordering::Relaxed) {
243+
tracing::debug!(msg_type = ?msg.msg_type, "Mirroring disabled, skipping");
244+
return Ok(());
245+
}
246+
218247
match msg.msg_type {
219248
MessageType::SessionStart => self.handle_session_start(&msg).await?,
220249
MessageType::SessionEnd => self.handle_session_end(&msg).await?,
@@ -268,6 +297,7 @@ impl BridgeShared {
268297
MessageType::SendImage => {
269298
self.handle_send_image(&msg).await?;
270299
}
300+
MessageType::Command => {} // already handled above
271301
_ => {
272302
tracing::debug!(msg_type = ?msg.msg_type, "Unhandled message type");
273303
}
@@ -1249,6 +1279,44 @@ impl BridgeShared {
12491279
}
12501280
}
12511281

1282+
// ============ Command Handling ============
1283+
1284+
async fn handle_command(&self, msg: &BridgeMessage) -> Result<()> {
1285+
let cmd = msg.content.trim().to_lowercase();
1286+
let new_state = match cmd.as_str() {
1287+
"toggle" => !self.mirroring_enabled.load(Ordering::Relaxed),
1288+
"enable" | "on" => true,
1289+
"disable" | "off" => false,
1290+
_ => {
1291+
tracing::debug!(cmd = %cmd, "Unknown command");
1292+
return Ok(());
1293+
}
1294+
};
1295+
1296+
self.mirroring_enabled.store(new_state, Ordering::Relaxed);
1297+
crate::config::write_mirror_status(
1298+
&self.config.config_dir,
1299+
new_state,
1300+
Some(std::process::id()),
1301+
);
1302+
1303+
let status_text = if new_state {
1304+
"\u{1f7e2} *Telegram mirroring: ON*"
1305+
} else {
1306+
"\u{1f534} *Telegram mirroring: OFF*"
1307+
};
1308+
1309+
tracing::info!(enabled = new_state, "Mirroring toggled");
1310+
1311+
// Send one confirmation message to Telegram (even when disabling)
1312+
let _ = self
1313+
.bot
1314+
.send_message(status_text, &SendOptions::default(), None)
1315+
.await;
1316+
1317+
Ok(())
1318+
}
1319+
12521320
// ============ Helper Methods ============
12531321

12541322
async fn get_session_thread_id(&self, session_id: &str) -> Option<i32> {

src/config.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::error::{AppError, Result};
2-
use serde::Deserialize;
2+
use serde::{Deserialize, Serialize};
33
use std::env;
44
use std::fs;
55
use std::os::unix::fs::PermissionsExt;
@@ -292,6 +292,46 @@ pub fn validate_config(config: &Config) -> (Vec<String>, Vec<String>) {
292292
(errors, warnings)
293293
}
294294

295+
// ============ Mirror Status (runtime toggle state) ============
296+
297+
#[derive(Debug, Clone, Serialize, Deserialize)]
298+
pub struct MirrorStatus {
299+
pub enabled: bool,
300+
#[serde(skip_serializing_if = "Option::is_none")]
301+
pub pid: Option<u32>,
302+
pub toggled_at: String,
303+
}
304+
305+
pub fn status_file_path(config_dir: &Path) -> PathBuf {
306+
config_dir.join("status.json")
307+
}
308+
309+
/// Read the current mirroring enabled state from status.json.
310+
/// Returns `true` (default) if the file doesn't exist or can't be parsed.
311+
pub fn read_mirror_status(config_dir: &Path) -> bool {
312+
let path = status_file_path(config_dir);
313+
match fs::read_to_string(&path) {
314+
Ok(content) => serde_json::from_str::<MirrorStatus>(&content)
315+
.map(|s| s.enabled)
316+
.unwrap_or(true),
317+
Err(_) => true,
318+
}
319+
}
320+
321+
/// Write the mirroring status file with secure permissions (0o600).
322+
pub fn write_mirror_status(config_dir: &Path, enabled: bool, pid: Option<u32>) {
323+
let status = MirrorStatus {
324+
enabled,
325+
pid,
326+
toggled_at: chrono::Utc::now().to_rfc3339(),
327+
};
328+
let path = status_file_path(config_dir);
329+
if let Ok(json) = serde_json::to_string_pretty(&status) {
330+
let _ = fs::write(&path, &json);
331+
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
332+
}
333+
}
334+
295335
#[cfg(test)]
296336
mod tests {
297337
use super::*;

src/hook.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ pub async fn process_hook(socket_path: &std::path::Path) -> Result<()> {
2020
return Ok(());
2121
}
2222

23+
// Fast path: check if mirroring is disabled via status.json (<1ms)
24+
if let Some(config_dir) = socket_path.parent() {
25+
if !crate::config::read_mirror_status(config_dir) {
26+
print!("{}", input);
27+
io::stdout().flush()?;
28+
return Ok(());
29+
}
30+
}
31+
2332
// Parse the hook event (Security fix #10: no unwrap/panic)
2433
let event: HookEvent = match serde_json::from_str(input) {
2534
Ok(e) => e,

0 commit comments

Comments
 (0)