Ambient system companions over one privacy-preserving signal daemon (aggregate-only, no keystroke content): a git-driven terminal garden and IOKit hardware collectors. ambient daemon macos privacy terminal

crates/signald/src/main.rs

186 lines · 7524 bytes

  1//! # signald
  2//!
  3//! The one long-lived, user-level daemon that owns every collector and
  4//! publishes a signal stream. Renderers are thin subscribers over a local Unix
  5//! socket — no renderer ever touches a sensor (spec §1.3).
  6//!
  7//! ```text
  8//!   zsh hooks ───▶ terminal collector ─┐   (aggregate counts from the spool)
  9//!   git/fsevents ▶ git collector ──────┼─▶ hub: last-value cache + fan-out
 10//!   IOKit/AppKit ▶ system+hw collector ┘   (macos-collector child, frames on stdout)
 11//!                                               ├─▶ publish: Unix socket (live)
 12//!                                               └─▶ SQLite WAL history
 13//! ```
 14//!
 15//! The git and terminal collectors run in-process on a tick; the hardware
 16//! collector is the sibling `macos-collector` binary, spawned as a child whose
 17//! stdout frames are ingested into the same hub. The hub caches the latest
 18//! value of every metric and streams updates to subscribers; every signal is
 19//! persisted to a SQLite (WAL) history store. Collector internals live in the
 20//! `signald` library crate; this binary is argument wiring, the producer loop,
 21//! and self-attestation.
 22//!
 23//! Usage:
 24//! ```text
 25//!   signald [--socket <path>] [--db <path>] [--spool <path>]
 26//!           [--collector <path>] [--interval-ms <n>] [--retention-days <n>]
 27//!           [<repo-path> ...]
 28//! ```
 29//! With no repo paths, the current directory is watched. The socket defaults to
 30//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
 31//! history db and terminal spool default alongside it. `--collector` names the
 32//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
 33//! with a log line, when absent. History older than `--retention-days`
 34//! (default 7) is pruned.
 35
 36use std::path::PathBuf;
 37use std::thread;
 38use std::time::Duration;
 39
 40use signal_schema::Source;
 41use signald::collectors;
 42use signald::history::History;
 43use signald::hub::Hub;
 44use signald::publish;
 45
 46struct Config {
 47    socket: PathBuf,
 48    db: PathBuf,
 49    spool: PathBuf,
 50    collector: Option<PathBuf>,
 51    interval: Duration,
 52    retention: Duration,
 53    repos: Vec<PathBuf>,
 54}
 55
 56fn main() {
 57    let cfg = parse_args();
 58    print_self_attestation(&cfg);
 59
 60    let history = match History::open_with_retention(&cfg.db, cfg.retention) {
 61        Ok(h) => {
 62            eprintln!(
 63                "signald: history at {} (retention {} days)",
 64                cfg.db.display(),
 65                cfg.retention.as_secs() / 86_400
 66            );
 67            h
 68        }
 69        Err(e) => {
 70            eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
 71            std::process::exit(1);
 72        }
 73    };
 74    let hub = Hub::with_history(history);
 75
 76    // Hardware: spawn the out-of-process collector and ingest its frames. Not
 77    // having one (Linux, or a dev build not on PATH) is not fatal.
 78    if let Some(collector) = &cfg.collector {
 79        match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) {
 80            Ok(_) => eprintln!("signald: hardware collector {}", collector.display()),
 81            Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()),
 82        }
 83    }
 84
 85    // Producer: poll the collectors on a tick and publish into the hub. Runs
 86    // for the life of the daemon, independent of any subscriber.
 87    let producer = hub.clone();
 88    let repos = cfg.repos.clone();
 89    let spool = cfg.spool.clone();
 90    let interval = cfg.interval;
 91    thread::spawn(move || {
 92        let mut terminal = collectors::terminal::Collector::new();
 93        loop {
 94            for repo in &repos {
 95                for sig in collectors::git::collect(repo) {
 96                    producer.publish(sig);
 97                }
 98            }
 99            for sig in terminal.collect(&spool) {
100                producer.publish(sig);
101            }
102            thread::sleep(interval);
103        }
104    });
105
106    eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
107    if let Err(e) = publish::serve(&cfg.socket, hub) {
108        eprintln!("signald: fatal: {e}");
109        std::process::exit(1);
110    }
111}
112
113fn parse_args() -> Config {
114    let mut socket: Option<PathBuf> = None;
115    let mut db: Option<PathBuf> = None;
116    let mut spool: Option<PathBuf> = None;
117    let mut collector: Option<PathBuf> = None;
118    let mut interval_ms: u64 = 2000;
119    let mut retention_days: u64 = 7;
120    let mut repos: Vec<PathBuf> = Vec::new();
121
122    let mut args = std::env::args().skip(1);
123    while let Some(arg) = args.next() {
124        match arg.as_str() {
125            "--socket" => socket = args.next().map(PathBuf::from),
126            "--db" => db = args.next().map(PathBuf::from),
127            "--spool" => spool = args.next().map(PathBuf::from),
128            "--collector" => collector = args.next().map(PathBuf::from),
129            "--interval-ms" => {
130                interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
131            }
132            "--retention-days" => {
133                retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
134            }
135            _ => repos.push(PathBuf::from(arg)),
136        }
137    }
138
139    if repos.is_empty() {
140        repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
141    }
142    let socket = socket.unwrap_or_else(default_socket_path);
143    let base = socket.parent().map(PathBuf::from).unwrap_or_default();
144    Config {
145        db: db.unwrap_or_else(|| base.join("signald.sqlite")),
146        spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
147        collector: collector.or_else(collectors::hardware::find_on_path),
148        interval: Duration::from_millis(interval_ms),
149        retention: Duration::from_secs(retention_days * 86_400),
150        repos,
151        socket,
152    }
153}
154
155/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
156fn default_socket_path() -> PathBuf {
157    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
158        return PathBuf::from(dir).join("signald.sock");
159    }
160    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
161    PathBuf::from(home).join(".local/state/signald/sock")
162}
163
164/// Log enabled collectors and assert none holds an input-tap capability. A real
165/// keylogger would need one of the forbidden APIs; their absence is the point,
166/// and this is the runtime half of that guarantee (spec §1.5).
167fn print_self_attestation(cfg: &Config) {
168    eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
169    eprintln!("  transport: unix socket (length-prefixed frames), live pub/sub");
170    eprintln!("  history:   sqlite (WAL), aggregate scalars only");
171    let hardware = match &cfg.collector {
172        Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
173        None => format!(
174            "DISABLED ({} not on PATH; pass --collector <path>)",
175            collectors::hardware::PROGRAM
176        ),
177    };
178    for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
179        let state = match source {
180            Source::Git => "ENABLED (aggregate scalars only)",
181            Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
182            Source::Macos | Source::Hardware => hardware.as_str(),
183        };
184        eprintln!("  collector {source:?}: input-tap capability = NONE — {state}");
185    }
186}