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

README.md

v0.4.0
ambient-companions/README.md rendered · source · history · blame · raw

241 lines · 13494 bytes

  1# ambient-companions
  2
  3*Five faces over one backend.*
  4
  5One signal-collection daemon; five thin renderers that subscribe to it. This is
  6closer to **one project with five faces** than five projects: the backend is
  7the project, and each face is thin, disposable, and deletable without touching
  8the others.
  9
 10## One backend, five faces
 11
 12```text
 13                signald  (user LaunchAgent)
 14  zsh hooks ───▶ terminal collector ─┐
 15  git/fsevents ▶ git collector ──────┼─▶ normalizer ─▶ ring buffer
 16  IOKit/AppKit ▶ system+hw collector ┘        │
 17 18                              publish: Unix socket + SQLite WAL (history)
 19        ┌───────────┬───────────┼───────────┬───────────────┐
 20        ▼           ▼           ▼           ▼               ▼
 21   terminal-pet  garden    menubar-pet  sonifier      wallpaperd
 22    (TUI)        (TUI)     (SwiftUI)    (AVAudio)     (image/window)
 23```
 24
 25All five draw from the **same signal bus**. Renderers never poll hardware,
 26never read the shell — they subscribe over a local Unix socket
 27(`$XDG_RUNTIME_DIR/signald.sock`). That buys one privacy boundary to defend
 28instead of five, cheap renderers, and reuse of the collection layer into
 29non-toy outputs (e-ink dashboards, printed posters).
 30
 31## The privacy boundary, enforced by construction
 32
 33Reading typing during real work is a keylogger unless it is *structurally*
 34content-free — and unless that is a **test**, not a sentence in a README.
 35
 36The invariant (spec §1.5):
 37
 38> No process persists, transmits, or exposes any representation from which the
 39> content or identity of an individual keystroke, command argument, or typed
 40> character can be recovered. Only order-free aggregates (counts, rates,
 41> durations, codes) leave the terminal collector.
 42
 43Made true by construction:
 44
 45- **No content channel exists in the wire format.** The only payload channel is
 46  `Value`, a newtype over `f64`. There is no `text`, `bytes`, or `payload`
 47  field — a collector *cannot* emit typed content because the record has
 48  nowhere to put it. The two audited exceptions are non-content identifiers in
 49  `tag` (bundle id, repo path, ssh host), allow-listed per metric name.
 50- **The key counter never stores the key**, there is **no input tap anywhere**
 51  (`CGEventTap`, `IOHIDManager` keyboard usage, accessibility observation are
 52  all forbidden), and **aggregation happens before transport** (the shell emits
 53  counts on `precmd`, never per-key events).
 54
 55And it is tested (`crates/signal-schema/tests/privacy_invariant.rs`):
 56
 57- `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
 58- `wire_format_has_no_content_field` — the `Signal` type declares no
 59  content-carrying field beyond the audited `tag`.
 60- `forbidden_symbol_scan` — the tree contains none of the banned keylogger APIs
 61  or shell line-buffer references (the static gate).
 62- `differential_secret_typing`**active (the ship gate).** It drives the real
 63  hook (`shell-hooks/signald-hooks.zsh`) through a real interactive zsh under a
 64  real pseudo-terminal (zsh's own `zsh/zpty` — no extra dependency), *typing a
 65  planted secret*, and asserts the secret never appears — plain, reversed, hex,
 66  or base64 — in the shell spool or the `f64`-only wire encoding. The **full
 67  pipeline** version (same real typing driven through the terminal collector,
 68  the SQLite history store, and the hub) is
 69  `crates/signald/tests/differential_secret_typing.rs`. Real `zle` keystroke
 70  counting increments a *number* per key and discards the key, so the only thing
 71  the pipeline ever receives about the typing is a count — the tests prove that
 72  empirically across every downstream artifact.
 73
 74## Build order
 75
 76Build the backend to the point each renderer needs, then build the renderer
 77that is cheapest *and* most valuable given what exists:
 78
 791. **garden** — git is the cleanest signal (discrete, no privacy questions), so
 80   it proves the bus first. *(recommended first face)*
 812. **gated collector + pet** — add the sensitive terminal collector only after
 82   the privacy tests are green and extended to cover it.
 833. **menu-bar permadeath pet** — first macOS-native face; ships the one-life
 84   variant (ages in wall-clock time, dies permanently) — the emotional hook.
 854. **SSH-sonification** — unexpected access becomes *audible*; the utility
 86   sonification before the ambient one.
 875. **homelab wallpaper** — desktop-as-status-board (Path A: render-to-image +
 88   `setDesktopImageURL`); the same frame pipeline later feeds e-ink and posters.
 89
 90## macOS collector constraint
 91
 92The system + hardware collector is **IOKit-only: no root, no `powermetrics`.**
 93`powermetrics` wants root and would make the suite un-shippable as a plain user
 94agent. Signals come from IOKit (`IOPMPowerSource` / power sources, IORegistry
 95`AppleSmartBattery`), `ProcessInfo`, and Mach `host_processor_info`. Where a
 96metric can't be reached without root, it is simply **absent from the schema**
 97rather than gated behind sudo. Accessibility permission is never requested.
 98
 99It ships as **`macos-collector/`**, a sibling **Swift** package (built with
100`swift build`, kept out of the cargo workspace — SwiftPM and cargo do not share
101a build system). It reads aggregate CPU load, battery %, charging, battery draw
102(W), and thermal state, and emits them as `signal-schema` wire frames — the
103same byte format `signald` parses. `signald` spawns it as a child process
104(found on `PATH`, or named with `--collector <path>`) and ingests the frames it
105writes to stdout, so hardware signals reach the hub, the history store, and
106every subscriber by the same path as git and terminal signals. GPU/fan are
107deliberately omitted (no clean root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
108Rust wire contract** (the byte layout) and the shared canonical-frame test that
109pins both sides to the same bytes.
110
111## Workspace layout
112
113```text
114ambient-companions/
115├── Cargo.toml                      # Rust workspace
116├── crates/
117│   ├── signal-schema/              # shared wire format (the privacy boundary)
118│   │   ├── src/lib.rs
119│   │   └── tests/privacy_invariant.rs
120│   ├── signald/                    # the daemon (collectors + live fan-out)
121│   │   ├── src/lib.rs              # git + terminal collectors, hardware ingest, publish
122│   │   ├── src/history.rs         # SQLite (WAL) history store + recent() query
123│   │   ├── src/hub.rs            # last-value cache + live fan-out
124│   │   ├── src/main.rs          # CLI, producer loop, self-attestation
125│   │   ├── tests/git_collector.rs           # aggregates vs a temp git repo
126│   │   ├── tests/streaming.rs               # last-value cache + live update
127│   │   ├── tests/hardware_ingest.rs         # collector frames reach a subscriber
128│   │   └── tests/differential_secret_typing.rs  # the full privacy ship-gate
129│   └── terminal-garden/            # first renderer: a socket subscriber
130│       ├── src/lib.rs             # signals → plots → render (unit-tested)
131│       └── src/main.rs           # live subscribe + redraw loop
132├── shell-hooks/                    # zsh hooks: aggregate-only terminal collector
133│   ├── signald-hooks.zsh
134│   └── README.md
135└── macos-collector/                # SwiftPM sibling (NOT in the cargo workspace):
136    ├── Package.swift              #   macOS IOKit hardware collector (Phase 3)
137    ├── README.md                 #   the Swift↔Rust wire contract (byte layout)
138    ├── Sources/CollectorCore/    #   wire encoder + IOKit reads (host_processor_
139    │                            #     info, power sources, AppleSmartBattery)
140    ├── Sources/macos-collector/  #   thin CLI: --once / stream / --hex / --out
141    └── Tests/                    #   shared canonical-frame wire-contract test
142```
143
144### Language note
145
146The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen
147because it makes the "no content field exists" guarantee enforceable in the
148type system (the `f64`-only `Value` payload), which is the whole point of the
149privacy boundary.
150
151### Dependencies
152
153`signal-schema` is **dependency-free** by design — the wire format is the
154privacy boundary and carries no third-party code. `signald` has **one**
155dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
156in-tree and there is no system-library requirement), for the WAL history store.
157The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
158and — for the differential test — `zpty` modules ship with zsh).
159
160## Phase plan
161
162- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
163  Workspace builds; the structural privacy tests pass.
164- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
165  The wire format (`encode`/`decode` + length-prefixed framing) is real and
166  `f64`-only. `signald`'s git collector shells out to `git` for aggregate
167  scalars — commits-in-window, commits-today, branch count, days-since-last-
168  commit — tagged by the audited repo path. `terminal-garden` subscribes and
169  renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
170  (🥀→🍂) tracks staleness.
171- **Persistence + live streaming. ✅ Done (v0.2).**
172  - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
173    is persisted (aggregate scalars only — same privacy constraints as the
174    wire), and `recent()` / `recent_named()` let a renderer read recent history,
175    not just the live snapshot.
176  - **Live streaming + last-value cache** (`signald/src/hub.rs`,
177    `publish::serve`): the daemon runs a producer loop and continuously
178    publishes. On connect a subscriber is handed the current value of every
179    metric immediately, then streams updates as they change (no longer
180    snapshot-then-close). `terminal-garden` now redraws live.
181- **Phase 2 — terminal collector. ✅ Done (v0.2).**
182  The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
183  counts keystrokes with a `zle` widget that increments a number and discards
184  the key, and appends `<epoch_ms> <keys> <session_seconds> <session_id>` count
185  records (numbers only) to a spool; `collectors::terminal` consumes the spool
186  and derives `keys_per_min` and `session_seconds`. The **differential
187  secret-typing test is active and passing** — the privacy ship-gate. *The
188  Terminal Pet renderer is still todo.*
189- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
190  `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
191  hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
192  (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
193  draw in watts (IORegistry `AppleSmartBattery`), and thermal state
194  (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
195  aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
196  omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
197  documented and pinned by a shared canonical-frame test on both sides
198  (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
199  Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
200  *Still todo:* the menu-bar permadeath pet.
201- **Hardware ingest. ✅ Done (v0.4).**
202  `signald` spawns `macos-collector` as a child (`--collector <path>`, or found
203  on `PATH`) and reads its stdout with the same `wire::read_frame` the socket
204  uses (`collectors::hardware`). The five hardware signals now appear in the
205  hub, the history store, and every subscriber's snapshot
206  (`crates/signald/tests/hardware_ingest.rs`).
207- **Bounded spool and history. ✅ Done (v0.4).**
208  The terminal spool is consumed each tick (renamed aside, read, deleted)
209  instead of re-read in full forever. Records carry the shell's pid, so
210  `keys_per_min` is each active shell's rate summed rather than a mix of
211  interleaved sessions, and `session_seconds` is the longest active shell.
212  History rows older than `--retention-days` (default 7) are pruned on open
213  and every 1000 inserts.
214- **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.*
215- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
216  *Out of scope.*
217
218## Build & test
219
220```sh
221cargo build          # whole Rust workspace
222cargo test           # includes the privacy invariant suite (must be green)
223
224# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
225cd macos-collector
226swift build          # builds the collector
227swift test           # the Swift↔Rust wire-contract test
228swift run macos-collector --once   # one real IOKit read (no root)
229```
230
231### Pre-push checks
232
233There is no CI. `.githooks/pre-push` is the gate: it runs `cargo test
234--locked`, `cargo clippy --all-targets --locked -- -D warnings`, and the
235`macos-collector` Swift tests, and a failure aborts the push. The differential
236secret-typing tests need `zsh` with the `zsh/zpty` module; the hook checks for
237it first and fails rather than skipping the gate. Enable it once per clone:
238
239```sh
240git config core.hooksPath .githooks
241```