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

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