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

147 lines · 5518 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 ┘   (stub, phase 3)
 11//!                                               ├─▶ publish: Unix socket (live)
 12//!                                               └─▶ SQLite WAL history
 13//! ```
 14//!
 15//! v0.2: the git and terminal collectors are live; the hub caches the latest
 16//! value of every metric and streams updates to subscribers; every signal is
 17//! persisted to a SQLite (WAL) history store. Collector internals live in the
 18//! `signald` library crate; this binary is argument wiring, the producer loop,
 19//! and self-attestation.
 20//!
 21//! Usage:
 22//! ```text
 23//!   signald [--socket <path>] [--db <path>] [--spool <path>]
 24//!           [--interval-ms <n>] [<repo-path> ...]
 25//! ```
 26//! With no repo paths, the current directory is watched. The socket defaults to
 27//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
 28//! history db and terminal spool default alongside it.
 29
 30use std::path::PathBuf;
 31use std::thread;
 32use std::time::Duration;
 33
 34use signal_schema::Source;
 35use signald::collectors;
 36use signald::history::History;
 37use signald::hub::Hub;
 38use signald::publish;
 39
 40struct Config {
 41    socket: PathBuf,
 42    db: PathBuf,
 43    spool: PathBuf,
 44    interval: Duration,
 45    repos: Vec<PathBuf>,
 46}
 47
 48fn main() {
 49    print_self_attestation();
 50    let cfg = parse_args();
 51
 52    let history = match History::open(&cfg.db) {
 53        Ok(h) => {
 54            eprintln!("signald: history at {}", cfg.db.display());
 55            h
 56        }
 57        Err(e) => {
 58            eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
 59            std::process::exit(1);
 60        }
 61    };
 62    let hub = Hub::with_history(history);
 63
 64    // Producer: poll the collectors on a tick and publish into the hub. Runs
 65    // for the life of the daemon, independent of any subscriber.
 66    let producer = hub.clone();
 67    let repos = cfg.repos.clone();
 68    let spool = cfg.spool.clone();
 69    let interval = cfg.interval;
 70    thread::spawn(move || loop {
 71        for repo in &repos {
 72            for sig in collectors::git::collect(repo) {
 73                producer.publish(sig);
 74            }
 75        }
 76        for sig in collectors::terminal::collect(&spool) {
 77            producer.publish(sig);
 78        }
 79        thread::sleep(interval);
 80    });
 81
 82    eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
 83    if let Err(e) = publish::serve(&cfg.socket, hub) {
 84        eprintln!("signald: fatal: {e}");
 85        std::process::exit(1);
 86    }
 87}
 88
 89fn parse_args() -> Config {
 90    let mut socket: Option<PathBuf> = None;
 91    let mut db: Option<PathBuf> = None;
 92    let mut spool: Option<PathBuf> = None;
 93    let mut interval_ms: u64 = 2000;
 94    let mut repos: Vec<PathBuf> = Vec::new();
 95
 96    let mut args = std::env::args().skip(1);
 97    while let Some(arg) = args.next() {
 98        match arg.as_str() {
 99            "--socket" => socket = args.next().map(PathBuf::from),
100            "--db" => db = args.next().map(PathBuf::from),
101            "--spool" => spool = args.next().map(PathBuf::from),
102            "--interval-ms" => {
103                interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
104            }
105            _ => repos.push(PathBuf::from(arg)),
106        }
107    }
108
109    if repos.is_empty() {
110        repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
111    }
112    let socket = socket.unwrap_or_else(default_socket_path);
113    let base = socket.parent().map(PathBuf::from).unwrap_or_default();
114    Config {
115        db: db.unwrap_or_else(|| base.join("signald.sqlite")),
116        spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
117        interval: Duration::from_millis(interval_ms),
118        repos,
119        socket,
120    }
121}
122
123/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
124fn default_socket_path() -> PathBuf {
125    if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
126        return PathBuf::from(dir).join("signald.sock");
127    }
128    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
129    PathBuf::from(home).join(".local/state/signald/sock")
130}
131
132/// Log enabled collectors and assert none holds an input-tap capability. A real
133/// keylogger would need one of the forbidden APIs; their absence is the point,
134/// and this is the runtime half of that guarantee (spec §1.5).
135fn print_self_attestation() {
136    eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
137    eprintln!("  transport: unix socket (length-prefixed frames), live pub/sub");
138    eprintln!("  history:   sqlite (WAL), aggregate scalars only");
139    for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
140        let state = match source {
141            Source::Git => "ENABLED (aggregate scalars only)",
142            Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
143            Source::Macos | Source::Hardware => "stub (phase 3)",
144        };
145        eprintln!("  collector {source:?}: input-tap capability = NONE — {state}");
146    }
147}