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

Commit 2ccb0bd1d1

2ccb0bd1d1b2d4ced1cb1036f441a79dd3a74121

parent: b3a7c31400

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-04 14:19 UTC

Strip references to the uncommitted spec and fix doc drift

The spec is not in the repo and never was, so 34 "spec §N" citations across 16
files pointed nowhere. Each is replaced with the rule it referred to, stated
plainly, or dropped where the surrounding sentence already said it.

Drift fixed:
- privacy_invariant.rs described the differential secret-typing test as a
  stubbed #[ignore] landing in Phase 2. It is active and is the ship gate.
- macos-collector/README.md pointed at a captured run in the project README
  that does not exist; it now names the command that prints real frames.
- The shell hooks claimed to emit exit codes in both the README and the
  script's own privacy contract. They emit a keystroke count and a session
  duration.
- Two stale "CI" mentions, left over from removing .gitbay/ci.yml.

The fourth drift item in the issue, signald's module docs calling the hardware
collector a stub, was already corrected when the ingest work landed.

Closes #7
README.md +2 −2
@@ -33,7 +33,7 @@ non-toy outputs (e-ink dashboards, printed posters).
3333Reading typing during real work is a keylogger unless it is *structurally*
3434content-free — and unless that is a **test**, not a sentence in a README.
3535
36The invariant (spec §1.5):
36The invariant:
3737
3838> No process persists, transmits, or exposes any representation from which the
3939> content or identity of an individual keystroke, command argument, or typed
@@ -144,7 +144,7 @@ ambient-companions/
144144
145145### Language note
146146
147The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen
147The daemon is written in **Rust**, chosen
148148because it makes the "no content field exists" guarantee enforceable in the
149149type system (the `f64`-only `Value` payload), which is the whole point of the
150150privacy boundary.
crates/signal-schema/src/lib.rs +2 −3
@@ -4,7 +4,7 @@
44//!
55//! ## The privacy boundary, made structural
66//!
7//! Everything in this suite is a footnote to one invariant (spec §1.5):
7//! Everything in this suite is a footnote to one invariant:
88//!
99//! > No process persists, transmits, or exposes any representation from which
1010//! > the content or identity of an individual keystroke, command argument, or
@@ -235,8 +235,7 @@ impl Signal {
235235 }
236236}
237237
238/// Length-prefixed wire encoding (spec §1.3: "length-prefixed framing over the
239/// Unix socket").
238/// Length-prefixed wire encoding, the framing used over the Unix socket.
240239///
241240/// The frame is a little-endian `u32` body length followed by the body:
242241///
crates/signal-schema/tests/privacy_invariant.rs +11 −11
@@ -1,10 +1,10 @@
1//! # The privacy invariant test (spec §1.5)
1//! # The privacy invariant test
22//!
33//! This is the single most important test in the project. It exists from day
44//! one, before the sensitive terminal collector does, so the guardrail is in
55//! place before the code it guards.
66//!
7//! The invariant (spec §1.5):
7//! The invariant:
88//!
99//! > No process persists, transmits, or exposes any representation from which
1010//! > the content or identity of an individual keystroke, command argument, or
@@ -17,12 +17,12 @@
1717//! content-carrying field (only `tag: Option<Tag>` is a string, and it is
1818//! the audited identifier exception).
1919//! 3. `forbidden_symbol_scan` — the tree contains none of the banned
20//! keylogger APIs / shell patterns (the static CI gate).
21//!
22//! The runtime *differential secret-typing* test (drive the hooks with a
23//! planted secret, assert it never reaches the socket or SQLite in any
24//! encoding) is stubbed `#[ignore]` below and lands with the terminal
25//! collector in Phase 2.
20//! keylogger APIs / shell patterns (the static gate).
21//! 4. `differential_secret_typing` — the runtime gate: drive the real hooks
22//! with a planted secret and assert it never reaches the spool or the
23//! wire in any encoding. Active. The full-pipeline version, through the
24//! collector, the history store and the hub, is
25//! `signald/tests/differential_secret_typing.rs`.
2626
2727use std::mem::size_of;
2828use std::path::PathBuf;
@@ -77,7 +77,7 @@ fn wire_format_has_no_content_field() {
7777 );
7878}
7979
80/// The static CI gate (spec §1.5): the tree must contain none of the APIs a
80/// The static gate: the tree must contain none of the APIs a
8181/// real keylogger would use, nor any shell hook that touches the line buffer.
8282/// Any hit fails the build. The banned list itself lives here and is reviewed.
8383#[test]
@@ -134,8 +134,8 @@ fn forbidden_symbol_scan() {
134134 );
135135}
136136
137/// The differential secret-typing acceptance test (spec §1.5, §5 Phase 2) —
138/// the ship gate for the terminal collector, now **active**.
137/// The differential secret-typing acceptance test — the ship gate for the
138/// terminal collector.
139139///
140140/// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`)
141141/// through a **real interactive zsh under a real pseudo-terminal** (zsh's own
crates/signald/src/history.rs +1 −1
@@ -1,4 +1,4 @@
1//! # History store (spec §1.3, §4)
1//! # History store
22//!
33//! A local SQLite (WAL) store of a rolling history of signals. History is what
44//! lets a renderer read *recent aggregates* rather than only a live snapshot —
crates/signald/src/hub.rs +2 −2
@@ -1,10 +1,10 @@
1//! # The signal hub — last-value cache + live fan-out (spec §1.3)
1//! # The signal hub — last-value cache + live fan-out
22//!
33//! The in-memory heart of the publish side. Collectors [`Hub::publish`] signals;
44//! subscribers [`Hub::subscribe`] and receive the current value of every cached
55//! metric immediately, then a live stream of updates.
66//!
7//! Subscription semantics (spec §1.3): *last-value-cache + live stream.* On
7//! Subscription semantics: *last-value-cache + live stream.* On
88//! subscribe, the caller is handed the latest value of each `name` (keyed by
99//! name + audited tag), so a renderer paints correct state at once; thereafter
1010//! it receives updates as they are published. Channels are unbounded, so a slow
crates/signald/src/lib.rs +8 −8
@@ -34,11 +34,11 @@ pub fn tag_within_roots(sig: &Signal, roots: &[PathBuf]) -> bool {
3434 roots.iter().any(|root| path.starts_with(root))
3535}
3636
37/// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4).
37/// Collectors: each reduces its domain to schema scalars.
3838pub mod collectors {
3939 /// Git collector: shell out to `git` for counts and ages. Counts and
4040 /// branch/age scalars only — never diff content. This is the cleanest
41 /// signal in the suite and the first collector wired (spec §2.2, §3).
41 /// signal in the suite and the first collector wired.
4242 pub mod git {
4343 use std::path::Path;
4444 use std::process::Command;
@@ -52,7 +52,7 @@ pub mod collectors {
5252
5353 /// Derive the aggregate git signals for one repo. Every value is a
5454 /// scalar `f64`; the only string on the wire is the repo path carried
55 /// in the audited `tag` (spec §1.2). Returns an empty vec if `repo` is
55 /// in the audited `tag`. Returns an empty vec if `repo` is
5656 /// not a git repo.
5757 pub fn collect(repo: &Path) -> Vec<Signal> {
5858 if !is_git_repo(repo) {
@@ -278,7 +278,7 @@ pub mod collectors {
278278 source: Source::Terminal,
279279 name,
280280 value: Value(value),
281 tag: None, // terminal aggregates are never tagged (spec §1.2)
281 tag: None, // terminal aggregates are never tagged
282282 };
283283 vec![
284284 mk(SignalName::KeysPerMin, keys_per_min),
@@ -363,7 +363,7 @@ pub mod collectors {
363363 }
364364
365365 /// System + hardware collector: **IOKit-only, no powermetrics, no root**
366 /// (spec §1.4). Native macOS.
366 /// Native macOS.
367367 ///
368368 /// The reads live out of process in the sibling Swift package
369369 /// `macos-collector/` (SwiftPM, not in this cargo workspace), which writes
@@ -436,13 +436,13 @@ pub mod collectors {
436436 }
437437}
438438
439/// The publish side (spec §1.3): a live pub/sub fan-out with a last-value cache.
439/// The publish side: a live pub/sub fan-out with a last-value cache.
440440///
441441/// On connect a subscriber is handed the current value of every cached `name`
442442/// immediately (so a renderer paints correct state at once), then streams
443443/// updates as they change. Each subscriber runs on its own thread with its own
444/// unbounded channel, so a slow subscriber never blocks the daemon (spec §1.3:
445/// "a stuck wallpaper process must not stall the audio thread's feed").
444/// unbounded channel, so a slow subscriber never blocks the daemon: one stuck
445/// renderer must not stall any other subscriber's feed.
446446pub mod publish {
447447 use std::io;
448448 use std::io::Write;
crates/signald/src/main.rs +2 −2
@@ -2,7 +2,7 @@
22//!
33//! The one long-lived, user-level daemon that owns every collector and
44//! publishes a signal stream. Renderers are thin subscribers over a local Unix
5//! socket — no renderer ever touches a sensor (spec §1.3).
5//! socket — no renderer ever touches a sensor.
66//!
77//! ```text
88//! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool)
@@ -167,7 +167,7 @@ fn default_socket_path() -> PathBuf {
167167
168168/// Log enabled collectors and assert none holds an input-tap capability. A real
169169/// keylogger would need one of the forbidden APIs; their absence is the point,
170/// and this is the runtime half of that guarantee (spec §1.5).
170/// and this is the runtime half of that guarantee.
171171fn print_self_attestation(cfg: &Config) {
172172 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
173173 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
crates/signald/tests/differential_secret_typing.rs +2 −2
@@ -1,4 +1,4 @@
1//! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5)
1//! # The differential secret-typing test — the privacy ship-gate
22//!
33//! This is the acceptance test for the whole terminal collector, and the single
44//! most important test in the project. It drives the **real production hook**
@@ -150,7 +150,7 @@ zpty -d SH 2>/dev/null
150150
151151// --- encodings + scanning ---
152152
153/// The secret in every representation the spec calls out (plain, reversed, hex,
153/// The secret in every representation worth checking (plain, reversed, hex,
154154/// base64), as raw byte needles to search for.
155155fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec<u8>)> {
156156 let b = secret.as_bytes();
crates/signald/tests/git_collector.rs +1 −1
@@ -1,4 +1,4 @@
1//! Git collector acceptance test (spec §5 Phase 1).
1//! Git collector acceptance test.
22//!
33//! Builds a real temp git repo, drives the collector against it, and asserts the
44//! aggregate scalars — commits-in-window, commits-today, branch count, and
crates/signald/tests/streaming.rs +1 −1
@@ -1,4 +1,4 @@
1//! Live-streaming acceptance test (spec §1.3): a subscriber gets the current
1//! Live-streaming acceptance test: a subscriber gets the current
22//! value of each metric immediately on connect (last-value cache), then receives
33//! updates as values change — over the real Unix socket, not just the hub.
44
crates/terminal-garden/src/lib.rs +2 −3
@@ -6,7 +6,7 @@
66//!
77//! Each watched repo is a plot; growth stage is a function of recent commit
88//! activity and health (wilt) is a function of days-since-last-commit
9//! (spec §2.2). A plant advances 🌱→🌿→🌳 as commits land, and droops then
9//! A plant advances 🌱→🌿→🌳 as commits land, and droops then
1010//! browns as a repo goes stale.
1111
1212use std::collections::BTreeMap;
@@ -148,8 +148,7 @@ pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> {
148148 by_repo.into_values().collect()
149149}
150150
151/// Render the whole garden to a text block (spec §2.2: a text render is fine for
152/// v0.1).
151/// Render the whole garden to a text block.
153152pub fn render(plots: &[Plot]) -> String {
154153 let mut out = String::new();
155154 out.push_str("terminal-garden\n");
crates/terminal-garden/src/main.rs +2 −2
@@ -1,6 +1,6 @@
11//! # terminal-garden
22//!
3//! The first face (spec §3: "the garden is the recommended first build").
3//! The first face of the bus: a renderer, not a collector.
44//! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy
55//! questions — so the garden proves the bus before anything reads the shell.
66//!
@@ -25,7 +25,7 @@ use std::path::PathBuf;
2525use signal_schema::{wire, Signal, SignalName};
2626use terminal_garden::{plots_from_signals, render};
2727
28/// The `name`s the garden cares about (spec §2.2 — the git aggregates).
28/// The `name`s the garden cares about: the git aggregates.
2929const SUBSCRIBE: &[SignalName] = &[
3030 SignalName::CommitsWindow,
3131 SignalName::CommitsToday,
macos-collector/README.md +5 −5
@@ -28,14 +28,14 @@ swift run macos-collector --once # one real IOKit read (summary on stderr)
2828
2929**Intentionally omitted:** GPU utilization and fan RPM. They are reachable in
3030principle via `IOReport`/AppleSMC, but only through chip-generation-specific,
31undocumented channels. Per spec §1.4/§6, a metric that cannot be read cleanly
32without root is **omitted from the schema rather than gated behind sudo**.
31undocumented channels. A metric that cannot be read cleanly without root is
32**omitted from the schema rather than gated behind sudo**.
3333Battery signals are omitted on a machine with no internal battery (a desktop).
3434
3535Every value is an **aggregate scalar**. This collector has no code path that can
3636read user content: it touches only hardware counters and power registers. The
3737`f64`-only payload and the absence of any input-tap API mean the privacy
38invariant (spec §1.5) holds for hardware signals exactly as it does for the
38invariant holds for hardware signals exactly as it does for the
3939terminal collector — and the repo's `forbidden_symbol_scan` gate scans these
4040`.swift` files too.
4141
@@ -94,8 +94,8 @@ This identical literal is asserted in **both**:
9494
9595Because both sides independently commit to the same bytes, cross-*process*
9696execution is not required to prove they agree — the shared literal is the proof.
97(A live `swift run macos-collector --once --hex` produces frames that decode
98byte-for-byte to this layout; see the project README for a captured run.)
97(`swift run macos-collector --once --hex` prints real frames in this layout if
98you want to check by hand.)
9999
100100## Wiring into signald
101101
macos-collector/Sources/CollectorCore/Hardware.swift +4 −4
@@ -2,7 +2,7 @@ import Foundation
22import IOKit
33import IOKit.ps
44
5// The hardware reads. IOKit-only, no `powermetrics`, no root (spec §1.4, §6):
5// The hardware reads. IOKit-only, no `powermetrics`, no root:
66// * CPU load Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` deltas
77// * battery % IOKit power sources (`IOPSCopyPowerSourcesInfo`)
88// * charging IOKit power sources
@@ -10,9 +10,9 @@ import IOKit.ps
1010// * thermal `ProcessInfo.thermalState` (aggregate enum, no root)
1111//
1212// GPU utilization and fan RPM are reachable in principle via `IOReport`/AppleSMC
13// but only with chip-generation-specific, undocumented channel spelunking; per
14// spec §1.4 a metric that cannot be read cleanly without root is *omitted from
15// the schema* rather than gated behind sudo. They are intentionally absent here.
13// but only with chip-generation-specific, undocumented channel spelunking. A
14// metric that cannot be read cleanly without root is *omitted from the schema*
15// rather than gated behind sudo. They are intentionally absent here.
1616//
1717// Every value produced is an aggregate scalar. There is no code path here that
1818// reads user content this file cannot, because it only ever touches hardware
shell-hooks/README.md +3 −3
@@ -1,14 +1,14 @@
11# shell-hooks
22
3Shell side of the terminal collector (spec §1.4). **Live as of v0.2.** These
3Shell side of the terminal collector. **Live as of v0.2.** These
44snippets belong in the dotfiles repo and are sourced by the user's `.zshrc`;
55they are kept here so the privacy contract lives next to the code it constrains
66(and so the differential secret-typing test can drive the real file).
77
88## The one rule
99
10**Aggregate-only.** The hooks emit counts, durations, and exit codes. They
11never read, store, or transmit the content of a command or a keystroke.
10**Aggregate-only.** The hooks emit a keystroke count and a session duration.
11They never read, store, or transmit the content of a command or a keystroke.
1212
1313- No input tap, no `CGEventTap`, no PTY sniffing.
1414- Never reference the `zle` line buffer or capture argv.
shell-hooks/signald-hooks.zsh +3 −3
@@ -1,11 +1,11 @@
1# signald-hooks.zsh — terminal collector, shell side (spec §1.4).
1# signald-hooks.zsh — terminal collector, shell side.
22#
33# A small sourced script that lives in the dotfiles repo and is sourced by the
44# user's .zshrc. It talks to signald by appending newline-delimited AGGREGATE
55# COUNT records to a spool file that signald reads. It holds no history.
66#
77# ============================ PRIVACY CONTRACT ============================
8# AGGREGATE-ONLY. This script emits COUNTS, DURATIONS, and EXIT CODES.
8# AGGREGATE-ONLY. This script emits a KEYSTROKE COUNT and a SESSION DURATION.
99# It NEVER reads, stores, or transmits the content of a command or a keystroke.
1010#
1111# - No input tap (none of the global event-tap / HID keyboard APIs). No
@@ -15,7 +15,7 @@
1515# it; the character is never assigned to a variable that outlives the widget
1616# and never leaves the shell. What leaves is a count.
1717# - It NEVER references the zle line buffer (the BUFFER/LBUFFER/RBUFFER zle
18# parameters) and NEVER captures argv. The forbidden-symbol CI scan
18# parameters) and NEVER captures argv. The forbidden-symbol scan
1919# (crates/signal-schema/tests/privacy_invariant.rs) fails the build if it
2020# ever does. That test — plus the differential secret-typing test — drives
2121# THIS FILE with a planted secret and asserts the secret never reaches the