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

343 lines · 13338 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::hub::Hub;
 47use signald::publish;
 48
 49struct Config {
 50    socket: PathBuf,
 51    db: PathBuf,
 52    spool: PathBuf,
 53    collector: Option<PathBuf>,
 54    interval: Duration,
 55    retention: Duration,
 56    repos: Vec<PathBuf>,
 57}
 58
 59fn main() {
 60    let cfg = parse_args();
 61    print_self_attestation(&cfg);
 62
 63    let history = match History::open_with_retention(&cfg.db, cfg.retention) {
 64        Ok(h) => {
 65            eprintln!(
 66                "signald: history at {} (retention {} days)",
 67                cfg.db.display(),
 68                cfg.retention.as_secs() / 86_400
 69            );
 70            h
 71        }
 72        Err(e) => {
 73            eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
 74            std::process::exit(1);
 75        }
 76    };
 77    let hub = Hub::with_history(history);
 78
 79    // Hardware: spawn the out-of-process collector and ingest its frames. Not
 80    // having one (Linux, or a dev build not on PATH) is not fatal.
 81    if let Some(collector) = &cfg.collector {
 82        match collectors::hardware::spawn(collector, cfg.interval.as_millis() as u64, hub.clone()) {
 83            Ok(_) => eprintln!("signald: hardware collector {}", collector.display()),
 84            Err(e) => eprintln!("signald: cannot start hardware collector {}: {e}", collector.display()),
 85        }
 86    }
 87
 88    // Producer: poll the collectors on a tick and publish into the hub. Runs
 89    // for the life of the daemon, independent of any subscriber.
 90    let producer = hub.clone();
 91    let repos = cfg.repos.clone();
 92    let spool = cfg.spool.clone();
 93    let interval = cfg.interval;
 94    thread::spawn(move || {
 95        let mut terminal = collectors::terminal::Collector::new();
 96        loop {
 97            for repo in &repos {
 98                for sig in collectors::git::collect(repo) {
 99                    // The audited repo-path tag never leaves the watched roots.
100                    if !signald::tag_within_roots(&sig, &repos) {
101                        continue;
102                    }
103                    producer.publish(sig);
104                }
105            }
106            for sig in terminal.collect(&spool) {
107                producer.publish(sig);
108            }
109            thread::sleep(interval);
110        }
111    });
112
113    eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
114    if let Err(e) = publish::serve(&cfg.socket, hub) {
115        eprintln!("signald: fatal: {e}");
116        std::process::exit(1);
117    }
118}
119
120const USAGE: &str = "\
121Usage: signald [options] [<repo-path> ...]
122
123  --socket <path>         listen here (default $XDG_RUNTIME_DIR/signald.sock,
124                          else ~/.local/state/signald/sock)
125  --db <path>             sqlite history (default: beside the socket)
126  --spool <path>          terminal spool (default: beside the socket)
127  --collector <path>      macos-collector binary (default: found on PATH)
128  --interval-ms <n>       collector tick (default 2000)
129  --retention-days <n>    history retention (default 7)
130  -h, --help              print this and exit
131  -V, --version           print the version and exit
132
133With no <repo-path>, $XDG_CONFIG_HOME/signald/repos (else
134~/.config/signald/repos) is read, one path per line, # comments ignored.
135Failing that, the working directory is watched.
136";
137
138fn parse_args() -> Config {
139    let mut socket: Option<PathBuf> = None;
140    let mut db: Option<PathBuf> = None;
141    let mut spool: Option<PathBuf> = None;
142    let mut collector: Option<PathBuf> = None;
143    let mut interval_ms: u64 = 2000;
144    let mut retention_days: u64 = 7;
145    let mut repos: Vec<PathBuf> = Vec::new();
146
147    let mut args = std::env::args().skip(1);
148    while let Some(arg) = args.next() {
149        match arg.as_str() {
150            "--socket" => socket = args.next().map(PathBuf::from),
151            "--db" => db = args.next().map(PathBuf::from),
152            "--spool" => spool = args.next().map(PathBuf::from),
153            "--collector" => collector = args.next().map(PathBuf::from),
154            "--interval-ms" => {
155                interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
156            }
157            "--retention-days" => {
158                retention_days = args.next().and_then(|s| s.parse().ok()).unwrap_or(retention_days)
159            }
160            "--help" | "-h" => {
161                print!("{USAGE}");
162                std::process::exit(0);
163            }
164            "--version" | "-V" => {
165                println!("signald {}", env!("CARGO_PKG_VERSION"));
166                std::process::exit(0);
167            }
168            // Without this an unknown flag becomes a repository path and the
169            // daemon starts anyway, watching a directory that does not exist.
170            _ if arg.starts_with('-') => {
171                eprintln!("signald: unknown option {arg}\n");
172                eprint!("{USAGE}");
173                std::process::exit(2);
174            }
175            _ => repos.push(PathBuf::from(arg)),
176        }
177    }
178
179    if repos.is_empty() {
180        repos = repos_from_config_file();
181    }
182    if repos.is_empty() {
183        repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
184    }
185    let socket = socket.unwrap_or_else(default_socket_path);
186    let base = socket.parent().map(PathBuf::from).unwrap_or_default();
187    Config {
188        db: db.unwrap_or_else(|| base.join("signald.sqlite")),
189        spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
190        collector: collector.or_else(collectors::hardware::find_on_path),
191        interval: Duration::from_millis(interval_ms),
192        retention: Duration::from_secs(retention_days * 86_400),
193        repos,
194        socket,
195    }
196}
197
198/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
199///
200/// Split from the environment so it can be tested without mutating it. The
201/// same resolution is duplicated in the other binary and in
202/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
203/// place they are written down, and the tests below pin them to it.
204fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
205    if let Some(dir) = xdg_runtime_dir {
206        return PathBuf::from(dir).join("signald.sock");
207    }
208    PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
209}
210
211fn default_socket_path() -> PathBuf {
212    socket_path_from(
213        std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
214        std::env::var("HOME").ok().as_deref(),
215    )
216}
217
218/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
219fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
220    match xdg_config_home {
221        Some(dir) => PathBuf::from(dir).join("signald/repos"),
222        None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
223    }
224}
225
226/// One repository path per line. Blank lines and `#` comments are ignored.
227fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
228    contents
229        .lines()
230        .map(|l| l.trim())
231        .filter(|l| !l.is_empty() && !l.starts_with('#'))
232        .map(PathBuf::from)
233        .collect()
234}
235
236/// Repositories to watch when none were given on the command line.
237///
238/// A login agent has no useful working directory — launchd starts it in `/` —
239/// so falling straight through to the cwd would leave `brew services start
240/// signald` collecting no git signals at all. A missing file is not an error;
241/// the cwd fallback still applies.
242fn repos_from_config_file() -> Vec<PathBuf> {
243    let path = repos_config_path(
244        std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
245        std::env::var("HOME").ok().as_deref(),
246    );
247    match std::fs::read_to_string(&path) {
248        Ok(contents) => {
249            let repos = parse_repos_file(&contents);
250            if !repos.is_empty() {
251                eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
252            }
253            repos
254        }
255        Err(_) => Vec::new(),
256    }
257}
258
259/// Log enabled collectors and assert none holds an input-tap capability. A real
260/// keylogger would need one of the forbidden APIs; their absence is the point,
261/// and this is the runtime half of that guarantee.
262fn print_self_attestation(cfg: &Config) {
263    eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
264    eprintln!("  transport: unix socket (length-prefixed frames), live pub/sub");
265    eprintln!("  history:   sqlite (WAL), aggregate scalars only");
266    let hardware = match &cfg.collector {
267        Some(p) => format!("ENABLED (out-of-process {}; IOKit only, no root)", p.display()),
268        None => format!(
269            "DISABLED ({} not on PATH; pass --collector <path>)",
270            collectors::hardware::PROGRAM
271        ),
272    };
273    for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
274        let state = match source {
275            Source::Git => "ENABLED (aggregate scalars only)",
276            Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
277            Source::Macos | Source::Hardware => hardware.as_str(),
278        };
279        eprintln!("  collector {source:?}: input-tap capability = NONE — {state}");
280    }
281}
282
283#[cfg(test)]
284mod path_tests {
285    use super::*;
286
287    /// Pinned to the README "Paths" table. If this changes, the table and
288    /// `shell-hooks/signald-hooks.zsh` change with it.
289    #[test]
290    fn socket_default_follows_xdg_then_home() {
291        assert_eq!(
292            socket_path_from(Some("/run/user/501"), Some("/Users/x")),
293            PathBuf::from("/run/user/501/signald.sock")
294        );
295        assert_eq!(
296            socket_path_from(None, Some("/Users/x")),
297            PathBuf::from("/Users/x/.local/state/signald/sock")
298        );
299    }
300
301    #[test]
302    fn repos_file_skips_blanks_and_comments() {
303        let repos = parse_repos_file(
304            "# what to watch\n\n/Users/x/git/one\n  /Users/x/git/two  \n\n# trailing\n",
305        );
306        assert_eq!(
307            repos,
308            vec![
309                PathBuf::from("/Users/x/git/one"),
310                PathBuf::from("/Users/x/git/two")
311            ]
312        );
313    }
314
315    #[test]
316    fn repos_config_follows_xdg_then_home() {
317        assert_eq!(
318            repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
319            PathBuf::from("/Users/x/.config/signald/repos")
320        );
321        assert_eq!(
322            repos_config_path(None, Some("/Users/x")),
323            PathBuf::from("/Users/x/.config/signald/repos")
324        );
325    }
326
327    /// The db and the spool are derived from the socket's directory, so all
328    /// three move together when --socket is given.
329    #[test]
330    fn db_and_spool_sit_beside_the_socket() {
331        let socket = socket_path_from(None, Some("/Users/x"));
332        let base = socket.parent().unwrap();
333        assert_eq!(
334            base.join("signald.sqlite"),
335            PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
336        );
337        assert_eq!(
338            base.join("terminal.spool"),
339            PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
340            "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
341        );
342    }
343}