synheart-sensor-agent 0.4.0

Privacy-first PC background sensor for behavioral research
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
//! Synheart Sensor Agent CLI
//!
//! Privacy-first behavioral sensor for research.
//! Pure collector — captures raw events and exposes them.
//! Feature extraction and session management handled by synheart-core-rust.

use clap::{Parser, Subcommand};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use synheart_sensor_agent::{
    collector::{check_permission, Collector, CollectorConfig, SensorEvent},
    config::{Config, SourceConfig},
    transparency::create_shared_log_with_persistence,
    PRIVACY_DECLARATION, VERSION,
};

#[derive(Parser)]
#[command(name = "synheart-sensor")]
#[command(author = "Synheart")]
#[command(version = VERSION)]
#[command(about = "Privacy-first behavioral sensor for research", long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Start capturing behavioral data (runs in the foreground)
    Start {
        /// Input sources to capture (keyboard, mouse, or all)
        #[arg(long, default_value = "all")]
        sources: String,
    },

    /// Start HTTP server to receive behavioral data from Chrome extension
    #[cfg(feature = "server")]
    Serve {
        /// Port to listen on
        #[arg(long, default_value = "8081")]
        port: u16,

        /// Bearer token required on POST /collect. Falls back to the
        /// SYNHEART_SENSOR_TOKEN environment variable. If neither is set, the
        /// endpoint is open (trusted loopback only).
        #[arg(long)]
        token: Option<String>,
    },

    /// Pause data collection
    Pause,

    /// Resume data collection
    Resume,

    /// Show current collection status
    Status,

    /// Display privacy declaration
    Privacy,

    /// Show configuration
    Config,
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Start { sources } => {
            cmd_start(&sources);
        }
        #[cfg(feature = "server")]
        Commands::Serve { port, token } => {
            cmd_serve(port, token);
        }
        Commands::Pause => {
            cmd_pause();
        }
        Commands::Resume => {
            cmd_resume();
        }
        Commands::Status => {
            cmd_status();
        }
        Commands::Privacy => {
            cmd_privacy();
        }
        Commands::Config => {
            cmd_config();
        }
    }
}

/// Load the saved configuration, falling back to defaults.
///
/// A missing config file is normal and silently yields defaults. A config file
/// that exists but fails to parse is surfaced as a warning (so a corrupt file is
/// not silently ignored) before falling back to defaults.
fn load_config() -> Config {
    match Config::load() {
        Ok(config) => config,
        Err(e) => {
            eprintln!("Warning: could not read config ({e}); using defaults.");
            Config::default()
        }
    }
}

fn cmd_start(sources: &str) {
    println!("Synheart Sensor Agent v{VERSION}");
    println!();

    // Check for input capture permission
    if !check_permission() {
        eprintln!("Error: Insufficient permissions to capture input events.");
        eprintln!();

        #[cfg(target_os = "macos")]
        {
            eprintln!("To grant permission:");
            eprintln!("1. Open System Preferences > Security & Privacy > Privacy");
            eprintln!("2. Select 'Input Monitoring' in the left sidebar");
            eprintln!("3. Add this application to the allowed list");
            eprintln!("4. Restart the application");
        }

        #[cfg(target_os = "windows")]
        {
            eprintln!("Possible causes:");
            eprintln!("1. The application may need to be run as Administrator");
            eprintln!("2. Antivirus or security software may be blocking input hooks");
            eprintln!("3. Group Policy may restrict low-level input access");
            eprintln!();
            eprintln!("Try running with elevated privileges (Run as Administrator).");
        }

        #[cfg(not(any(target_os = "macos", target_os = "windows")))]
        {
            eprintln!("Input capture is not supported on this platform.");
        }

        std::process::exit(1);
    }

    // Parse source configuration
    let source_config = SourceConfig::from_csv(sources);
    if !source_config.any_enabled() {
        eprintln!("Error: At least one source must be enabled (keyboard or mouse)");
        std::process::exit(1);
    }

    // Load or create configuration
    let config = load_config();
    if let Err(e) = config.ensure_directories() {
        eprintln!("Warning: Could not create directories: {e}");
    }

    println!("Starting collection...");
    println!(
        "  Keyboard: {}",
        if source_config.keyboard {
            "enabled"
        } else {
            "disabled"
        }
    );
    println!(
        "  Mouse: {}",
        if source_config.mouse {
            "enabled"
        } else {
            "disabled"
        }
    );
    println!();
    println!("Press Ctrl+C to stop");
    println!();

    // Set up transparency log
    let transparency_log =
        create_shared_log_with_persistence(config.data_path.join("transparency.json"));

    // Create collector
    let collector_config = CollectorConfig {
        capture_keyboard: source_config.keyboard,
        capture_mouse: source_config.mouse,
    };
    let mut collector = Collector::new(collector_config);

    // Event counter for status display
    let mut event_count: u64 = 0;
    let mut last_status = std::time::Instant::now();

    // Set up Ctrl+C handler
    let running = Arc::new(AtomicBool::new(true));
    let r = running.clone();
    ctrlc_handler(r);

    // Support pause/resume from another process by polling the config file.
    // Only re-parse it when its mtime changes so the steady-state poll is a stat.
    let config_path = Config::config_path();
    let mut last_config_mtime = config_mtime(&config_path);
    let mut paused = config.paused;
    let mut last_config_check = std::time::Instant::now();

    // Persist the transparency log periodically so a crash or SIGKILL doesn't
    // discard the whole session's counts.
    let mut last_log_save = std::time::Instant::now();
    const LOG_SAVE_INTERVAL: Duration = Duration::from_secs(60);

    let mut collector_died = false;

    if paused {
        println!("Collection is currently paused.");
        println!("Run `synheart-sensor resume` to start collecting.");
        println!();
    } else if let Err(e) = collector.start() {
        eprintln!("Error starting collector: {e}");
        std::process::exit(1);
    }

    // Main event loop — pure capture, no processing
    let receiver = collector.receiver().clone();

    while running.load(Ordering::SeqCst) {
        // Periodically reload config for pause/resume control.
        if last_config_check.elapsed() >= Duration::from_secs(1) {
            let mtime = config_mtime(&config_path);
            if mtime != last_config_mtime {
                last_config_mtime = mtime;
                if let Ok(cfg) = Config::load() {
                    if cfg.paused != paused {
                        paused = cfg.paused;

                        if paused {
                            println!();
                            println!("Pausing collection...");
                            collector.stop();
                            while receiver.try_recv().is_ok() {}
                        } else {
                            println!();
                            println!("Resuming collection...");
                            if let Err(e) = collector.start() {
                                eprintln!("Error resuming collector: {e}");
                                std::process::exit(1);
                            }
                        }
                    }
                }
            }
            last_config_check = std::time::Instant::now();
        }

        if last_log_save.elapsed() >= LOG_SAVE_INTERVAL {
            if let Err(e) = transparency_log.save() {
                eprintln!("Warning: Could not save transparency log: {e}");
            }
            last_log_save = std::time::Instant::now();
        }

        if paused {
            thread::sleep(Duration::from_millis(100));
            continue;
        }

        // Detect a capture thread that died (e.g. permission revoked mid-run)
        // instead of idling forever while collecting nothing.
        if !collector.is_running() {
            collector_died = true;
            break;
        }

        // Process events
        match receiver.recv_timeout(Duration::from_millis(100)) {
            Ok(event) => {
                // Update transparency log
                match &event {
                    SensorEvent::Keyboard(_) => transparency_log.record_keyboard_event(),
                    SensorEvent::Mouse(_) => transparency_log.record_mouse_event(),
                    SensorEvent::Shortcut(_) => transparency_log.record_shortcut_event(),
                }

                event_count += 1;

                // Periodic status line
                if last_status.elapsed() >= Duration::from_secs(10) {
                    let dropped = collector.dropped_count();
                    if dropped > 0 {
                        println!("[sensor] {event_count} events captured ({dropped} dropped)");
                    } else {
                        println!("[sensor] {event_count} events captured");
                    }
                    last_status = std::time::Instant::now();
                }
            }
            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
                eprintln!("Collector disconnected unexpectedly");
                break;
            }
        }
    }

    // Count any events still buffered in the channel before shutting down.
    while let Ok(event) = receiver.try_recv() {
        match &event {
            SensorEvent::Keyboard(_) => transparency_log.record_keyboard_event(),
            SensorEvent::Mouse(_) => transparency_log.record_mouse_event(),
            SensorEvent::Shortcut(_) => transparency_log.record_shortcut_event(),
        }
    }

    // Stop collection
    println!();
    println!("Stopping collection...");
    collector.stop();

    // Save transparency log
    if let Err(e) = transparency_log.save() {
        eprintln!("Warning: Could not save transparency log: {e}");
    }

    // Final stats
    println!();
    println!("{}", transparency_log.summary());
    let dropped = collector.dropped_count();
    if dropped > 0 {
        println!("Events dropped (channel full): {dropped}");
    }

    if collector_died {
        eprintln!();
        eprintln!("Error: the capture thread stopped unexpectedly.");
        eprintln!(
            "Input capture permission may have been revoked; check `synheart-sensor status`."
        );
        std::process::exit(1);
    }
}

/// Modification time of the config file, or `None` if it doesn't exist yet.
fn config_mtime(path: &std::path::Path) -> Option<std::time::SystemTime> {
    std::fs::metadata(path).and_then(|m| m.modified()).ok()
}

/// Start HTTP server for receiving behavioral data from Chrome extension
#[cfg(feature = "server")]
fn cmd_serve(port: u16, token: Option<String>) {
    use synheart_sensor_agent::server::ServerConfig;

    // Resolve the bearer token from the flag, falling back to the environment.
    let token = token.or_else(|| std::env::var("SYNHEART_SENSOR_TOKEN").ok());

    println!("Synheart Sensor Agent v{VERSION}");
    println!();
    println!("Starting HTTP server for Chrome extension...");
    println!("  Listen port: {port}");
    println!(
        "  Authentication: {}",
        if token.is_some() {
            "bearer token required"
        } else {
            "disabled (loopback only)"
        }
    );
    println!();

    // Load config for state directory
    let config = load_config();
    if let Err(e) = config.ensure_directories() {
        eprintln!("Warning: Could not create directories: {e}");
    }

    // Create server config
    let server_config = ServerConfig::new(port, config.data_path.clone()).with_token(token);

    // Set up runtime
    let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");

    rt.block_on(async {
        // Initialize tracing
        tracing_subscriber::fmt()
            .with_env_filter(
                tracing_subscriber::EnvFilter::from_default_env()
                    .add_directive(tracing::Level::INFO.into()),
            )
            .init();

        match synheart_sensor_agent::server::run(server_config).await {
            Ok((addr, shutdown_tx)) => {
                println!("Server listening on http://{addr}");
                println!();
                println!("POST data to: http://{addr}/collect");
                println!();
                println!("Press Ctrl+C to stop");
                println!();

                // Wait for Ctrl+C
                let _ = tokio::signal::ctrl_c().await;

                println!();
                println!("Shutting down server...");
                let _ = shutdown_tx.send(());
            }
            Err(e) => {
                eprintln!("Failed to start server: {e}");
                std::process::exit(1);
            }
        }
    });
}

fn cmd_pause() {
    let mut config = load_config();
    config.paused = true;
    if let Err(e) = config.save() {
        eprintln!("Error saving config: {e}");
        std::process::exit(1);
    }
    println!("Collection paused. Use 'synheart-sensor resume' to continue.");
}

fn cmd_resume() {
    let mut config = load_config();
    config.paused = false;
    if let Err(e) = config.save() {
        eprintln!("Error saving config: {e}");
        std::process::exit(1);
    }
    println!("Collection resumed.");
}

fn cmd_status() {
    let config = load_config();

    println!("Synheart Sensor Agent Status");
    println!("============================");
    println!();

    // Check permission
    let has_permission = check_permission();
    println!(
        "Input Capture Permission: {}",
        if has_permission {
            "Granted"
        } else {
            "Not Granted"
        }
    );
    println!();

    // Show config
    println!("Configuration:");
    println!(
        "  Keyboard capture: {}",
        if config.sources.keyboard {
            "enabled"
        } else {
            "disabled"
        }
    );
    println!(
        "  Mouse capture: {}",
        if config.sources.mouse {
            "enabled"
        } else {
            "disabled"
        }
    );
    println!("  Paused: {}", config.paused);
    println!();

    // Load and show transparency stats if available
    let stats_path = config.data_path.join("transparency.json");
    if stats_path.exists() {
        if let Ok(content) = std::fs::read_to_string(&stats_path) {
            if let Ok(stats) = serde_json::from_str::<serde_json::Value>(&content) {
                println!("Cumulative Statistics:");
                if let Some(kb) = stats.get("keyboard_events") {
                    println!("  Keyboard events: {kb}");
                }
                if let Some(mouse) = stats.get("mouse_events") {
                    println!("  Mouse events: {mouse}");
                }
            }
        }
    } else {
        println!("No previous session data found.");
    }
}

fn cmd_privacy() {
    println!("{PRIVACY_DECLARATION}");
}

fn cmd_config() {
    let config = load_config();

    println!("Configuration");
    println!("=============");
    println!();
    println!("Config file: {:?}", Config::config_path());
    println!();
    println!(
        "{}",
        serde_json::to_string_pretty(&config).unwrap_or_else(|_| "Error".to_string())
    );
}

/// Set up Ctrl+C handler.
fn ctrlc_handler(running: Arc<AtomicBool>) {
    ctrlc::set_handler(move || {
        running.store(false, Ordering::SeqCst);
    })
    .expect("Error setting Ctrl+C handler");
}