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

287 lines · 11263 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.
  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//!   signald --help | --version
 29//! ```
 30//! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else
 31//! `~/.config/signald/repos`) is read — one path per line, `#` comments
 32//! ignored — and failing that the current directory is watched. The socket defaults to
 33//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
 34//! history db and terminal spool default alongside it. `--collector` names the
 35//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
 36//! with a log line, when absent. History older than `--retention-days`
 37//! (default 7) is pruned.
 38
 39use std::path::PathBuf;
 40use std::thread;
 41use std::time::Duration;
 42
 43use signal_schema::Source;
 44use signald::collectors;
 45use signald::history::History;
 46use signald::supervisor;
 47use signald::hub::Hub;
 48use signald::publish;
 49
 50struct Config {
 51    socket: PathBuf,
 52    db: PathBuf,
 53    spool: PathBuf,
 54    collector: Option<PathBuf>,
 55    interval: Duration,
 56    retention: Duration,
 57    repos: Vec<PathBuf>,
 58}
 59
 60fn main() {
 61    let cfg = parse_args();
 62    print_self_attestation(&cfg);
 63
 64    let history = match History::open_with_retention(&cfg.db, cfg.retention) {
 65        Ok(h) => {
 66            eprintln!(
 67                "signald: history at {} (retention {} days)",
 68                cfg.db.display(),
 69                cfg.retention.as_secs() / 86_400
 70            );
 71            h
 72        }
 73        Err(e) => {
 74            eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
 75            std::process::exit(1);
 76        }
 77    };
 78    let hub = Hub::with_history(history);
 79
 80    // Every collector reports through one Health, so a state change reaches the
 81    // bus once and only when it is a change.
 82    let health = supervisor::Health::new();
 83
 84    // Hardware: keep the out-of-process collector running. Not having one
 85    // (Linux, or a dev build not on PATH) is not fatal and is not retried.
 86    match &cfg.collector {
 87        Some(collector) => {
 88            eprintln!("signald: hardware collector {}", collector.display());
 89            let (hub, health) = (hub.clone(), health.clone());
 90            let collector = collector.clone();
 91            let interval_ms = cfg.interval.as_millis() as u64;
 92            thread::spawn(move || supervisor::supervise_hardware(hub, health, collector, interval_ms));
 93        }
 94        None => supervisor::report_unconfigured(&hub, &health, collectors::hardware::PROGRAM),
 95    }
 96
 97    // Producer: poll the git and terminal collectors on a tick and publish into
 98    // the hub. Runs for the life of the daemon, independent of any subscriber.
 99    {
100        let (hub, health) = (hub.clone(), health.clone());
101        let repos = cfg.repos.clone();
102        let spool = cfg.spool.clone();
103        let interval = cfg.interval;
104        thread::spawn(move || supervisor::run_producer(hub, health, repos, spool, interval));
105    }
106
107    eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
108    if let Err(e) = publish::serve(&cfg.socket, hub) {
109        eprintln!("signald: fatal: {e}");
110        std::process::exit(1);
111    }
112}
113
114const USAGE: &str = "\
115Usage: signald [options] [<repo-path> ...]
116
117  --socket <path>         listen here (default $XDG_RUNTIME_DIR/signald.sock,
118                          else ~/.local/state/signald/sock)
119  --db <path>             sqlite history (default: beside the socket)
120  --spool <path>          terminal spool (default: beside the socket)
121  --collector <path>      macos-collector binary (default: found on PATH)
122  --interval-ms <n>       collector tick (default 2000)
123  --retention-days <n>    history retention (default 7)
124  -h, --help              print this and exit
125  -V, --version           print the version and exit
126
127With no <repo-path>, $XDG_CONFIG_HOME/signald/repos (else
128~/.config/signald/repos) is read, one path per line, # comments ignored.
129Failing that, the working directory is watched.
130";
131
132fn parse_args() -> Config {
133    let mut socket: Option<PathBuf> = None;
134    let mut db: Option<PathBuf> = None;
135    let mut spool: Option<PathBuf> = None;
136    let mut collector: Option<PathBuf> = None;
137    let mut interval_ms: u64 = 2000;
138    let mut retention_days: u64 = 7;
139    let mut repos: Vec<PathBuf> = Vec::new();
140
141    let mut args = std::env::args().skip(1);
142    while let Some(arg) = args.next() {
143        match arg.as_str() {
144            "--socket" => socket = args.next().map(PathBuf::from),
145            "--db" => db = args.next().map(PathBuf::from),
146            "--spool" => spool = args.next().map(PathBuf::from),
147            "--collector" => collector = args.next().map(PathBuf::from),
148            "--interval-ms" => {
149                interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
150            }
151            "--retention-days" => {
152                retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
153            }
154            "--help" | "-h" => {
155                print!("{USAGE}");
156                std::process::exit(0);
157            }
158            "--version" | "-V" => {
159                println!("signald {}", env!("CARGO_PKG_VERSION"));
160                std::process::exit(0);
161            }
162            // Without this an unknown flag becomes a repository path and the
163            // daemon starts anyway, watching a directory that does not exist.
164            _ if arg.starts_with('-') => {
165                eprintln!("signald: unknown option {arg}\n");
166                eprint!("{USAGE}");
167                std::process::exit(2);
168            }
169            _ => repos.push(PathBuf::from(arg)),
170        }
171    }
172
173    if repos.is_empty() {
174        repos = repos_from_config_file();
175    }
176    if repos.is_empty() {
177        repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
178    }
179    let socket = socket.unwrap_or_else(signal_client::default_socket_path);
180    let base = socket.parent().map(PathBuf::from).unwrap_or_default();
181    Config {
182        db: db.unwrap_or_else(|| base.join("signald.sqlite")),
183        spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
184        collector: collector.or_else(collectors::hardware::find_on_path),
185        interval: Duration::from_millis(interval_ms),
186        retention: Duration::from_secs(retention_days * 86_400),
187        repos,
188        socket,
189    }
190}
191
192/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
193fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
194    match xdg_config_home {
195        Some(dir) => PathBuf::from(dir).join("signald/repos"),
196        None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
197    }
198}
199
200/// One repository path per line. Blank lines and `#` comments are ignored.
201fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
202    contents
203        .lines()
204        .map(|l| l.trim())
205        .filter(|l| !l.is_empty() && !l.starts_with('#'))
206        .map(PathBuf::from)
207        .collect()
208}
209
210/// Repositories to watch when none were given on the command line.
211///
212/// A login agent has no useful working directory — launchd starts it in `/` —
213/// so falling straight through to the cwd would leave `brew services start
214/// signald` collecting no git signals at all. A missing file is not an error;
215/// the cwd fallback still applies.
216fn repos_from_config_file() -> Vec<PathBuf> {
217    let path = repos_config_path(
218        std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
219        std::env::var("HOME").ok().as_deref(),
220    );
221    match std::fs::read_to_string(&path) {
222        Ok(contents) => {
223            let repos = parse_repos_file(&contents);
224            if !repos.is_empty() {
225                eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
226            }
227            repos
228        }
229        Err(_) => Vec::new(),
230    }
231}
232
233/// Log enabled collectors and assert none holds an input-tap capability. A real
234/// keylogger would need one of the forbidden APIs; their absence is the point,
235/// and this is the runtime half of that guarantee.
236fn print_self_attestation(cfg: &Config) {
237    eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
238    eprintln!("  transport: unix socket (length-prefixed frames), live pub/sub");
239    eprintln!("  history:   sqlite (WAL), aggregate scalars only");
240    let hardware = match &cfg.collector {
241        Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
242        None => format!(
243            "DISABLED ({} not on PATH; pass --collector <path>)",
244            collectors::hardware::PROGRAM
245        ),
246    };
247    for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
248        let state = match source {
249            Source::Git => "ENABLED (aggregate scalars only)",
250            Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
251            Source::Macos | Source::Hardware => hardware.as_str(),
252        };
253        eprintln!("  collector {source:?}: input-tap capability = NONE — {state}");
254    }
255}
256
257#[cfg(test)]
258mod path_tests {
259    use super::*;
260
261    #[test]
262    fn repos_file_skips_blanks_and_comments() {
263        let repos = parse_repos_file(
264            "# what to watch\n\n/Users/x/git/one\n  /Users/x/git/two  \n\n# trailing\n",
265        );
266        assert_eq!(
267            repos,
268            vec![
269                PathBuf::from("/Users/x/git/one"),
270                PathBuf::from("/Users/x/git/two")
271            ]
272        );
273    }
274
275    #[test]
276    fn repos_config_follows_xdg_then_home() {
277        assert_eq!(
278            repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
279            PathBuf::from("/Users/x/.config/signald/repos")
280        );
281        assert_eq!(
282            repos_config_path(None, Some("/Users/x")),
283            PathBuf::from("/Users/x/.config/signald/repos")
284        );
285    }
286
287}