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 27835ca586

27835ca5867fef852c904c75628c5b54416ead7a

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 02:56 UTC

ambient companions v0.3: signal daemon, git/terminal/IOKit collectors, privacy-gated f64-only schema
.gitignore added +1
@@ -0,0 +1 @@
1/target
Cargo.lock added +306
@@ -0,0 +1,306 @@
1# This file is automatically @generated by Cargo.
2# It is not intended for manual editing.
3version = 4
4
5[[package]]
6name = "bitflags"
7version = "2.13.1"
8source = "registry+https://github.com/rust-lang/crates.io-index"
9checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
10
11[[package]]
12name = "bumpalo"
13version = "3.20.3"
14source = "registry+https://github.com/rust-lang/crates.io-index"
15checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
16
17[[package]]
18name = "cc"
19version = "1.4.2"
20source = "registry+https://github.com/rust-lang/crates.io-index"
21checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
22dependencies = [
23 "find-msvc-tools",
24 "shlex",
25]
26
27[[package]]
28name = "cfg-if"
29version = "1.0.4"
30source = "registry+https://github.com/rust-lang/crates.io-index"
31checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
32
33[[package]]
34name = "fallible-iterator"
35version = "0.3.0"
36source = "registry+https://github.com/rust-lang/crates.io-index"
37checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
38
39[[package]]
40name = "fallible-streaming-iterator"
41version = "0.1.9"
42source = "registry+https://github.com/rust-lang/crates.io-index"
43checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
44
45[[package]]
46name = "find-msvc-tools"
47version = "0.1.10"
48source = "registry+https://github.com/rust-lang/crates.io-index"
49checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
50
51[[package]]
52name = "foldhash"
53version = "0.2.0"
54source = "registry+https://github.com/rust-lang/crates.io-index"
55checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
56
57[[package]]
58name = "hashbrown"
59version = "0.16.1"
60source = "registry+https://github.com/rust-lang/crates.io-index"
61checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
62dependencies = [
63 "foldhash",
64]
65
66[[package]]
67name = "hashbrown"
68version = "0.17.1"
69source = "registry+https://github.com/rust-lang/crates.io-index"
70checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
71dependencies = [
72 "foldhash",
73]
74
75[[package]]
76name = "hashlink"
77version = "0.12.1"
78source = "registry+https://github.com/rust-lang/crates.io-index"
79checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
80dependencies = [
81 "hashbrown 0.17.1",
82]
83
84[[package]]
85name = "js-sys"
86version = "0.3.104"
87source = "registry+https://github.com/rust-lang/crates.io-index"
88checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
89dependencies = [
90 "cfg-if",
91 "wasm-bindgen",
92]
93
94[[package]]
95name = "libsqlite3-sys"
96version = "0.38.2"
97source = "registry+https://github.com/rust-lang/crates.io-index"
98checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
99dependencies = [
100 "cc",
101 "pkg-config",
102 "vcpkg",
103]
104
105[[package]]
106name = "once_cell"
107version = "1.21.4"
108source = "registry+https://github.com/rust-lang/crates.io-index"
109checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
110
111[[package]]
112name = "pkg-config"
113version = "0.3.33"
114source = "registry+https://github.com/rust-lang/crates.io-index"
115checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
116
117[[package]]
118name = "proc-macro2"
119version = "1.0.107"
120source = "registry+https://github.com/rust-lang/crates.io-index"
121checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
122dependencies = [
123 "unicode-ident",
124]
125
126[[package]]
127name = "quote"
128version = "1.0.47"
129source = "registry+https://github.com/rust-lang/crates.io-index"
130checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
131dependencies = [
132 "proc-macro2",
133]
134
135[[package]]
136name = "rsqlite-vfs"
137version = "0.1.1"
138source = "registry+https://github.com/rust-lang/crates.io-index"
139checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
140dependencies = [
141 "hashbrown 0.16.1",
142 "thiserror",
143]
144
145[[package]]
146name = "rusqlite"
147version = "0.40.2"
148source = "registry+https://github.com/rust-lang/crates.io-index"
149checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
150dependencies = [
151 "bitflags",
152 "fallible-iterator",
153 "fallible-streaming-iterator",
154 "hashlink",
155 "libsqlite3-sys",
156 "smallvec",
157 "sqlite-wasm-rs",
158]
159
160[[package]]
161name = "rustversion"
162version = "1.0.23"
163source = "registry+https://github.com/rust-lang/crates.io-index"
164checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
165
166[[package]]
167name = "shlex"
168version = "2.0.1"
169source = "registry+https://github.com/rust-lang/crates.io-index"
170checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
171
172[[package]]
173name = "signal-schema"
174version = "0.3.0"
175
176[[package]]
177name = "signald"
178version = "0.3.0"
179dependencies = [
180 "rusqlite",
181 "signal-schema",
182]
183
184[[package]]
185name = "smallvec"
186version = "1.15.2"
187source = "registry+https://github.com/rust-lang/crates.io-index"
188checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
189
190[[package]]
191name = "sqlite-wasm-rs"
192version = "0.5.5"
193source = "registry+https://github.com/rust-lang/crates.io-index"
194checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
195dependencies = [
196 "cc",
197 "js-sys",
198 "rsqlite-vfs",
199 "wasm-bindgen",
200]
201
202[[package]]
203name = "syn"
204version = "2.0.119"
205source = "registry+https://github.com/rust-lang/crates.io-index"
206checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
207dependencies = [
208 "proc-macro2",
209 "quote",
210 "unicode-ident",
211]
212
213[[package]]
214name = "syn"
215version = "3.0.3"
216source = "registry+https://github.com/rust-lang/crates.io-index"
217checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
218dependencies = [
219 "proc-macro2",
220 "quote",
221 "unicode-ident",
222]
223
224[[package]]
225name = "terminal-garden"
226version = "0.3.0"
227dependencies = [
228 "signal-schema",
229]
230
231[[package]]
232name = "thiserror"
233version = "2.0.20"
234source = "registry+https://github.com/rust-lang/crates.io-index"
235checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
236dependencies = [
237 "thiserror-impl",
238]
239
240[[package]]
241name = "thiserror-impl"
242version = "2.0.20"
243source = "registry+https://github.com/rust-lang/crates.io-index"
244checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
245dependencies = [
246 "proc-macro2",
247 "quote",
248 "syn 3.0.3",
249]
250
251[[package]]
252name = "unicode-ident"
253version = "1.0.24"
254source = "registry+https://github.com/rust-lang/crates.io-index"
255checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
256
257[[package]]
258name = "vcpkg"
259version = "0.2.15"
260source = "registry+https://github.com/rust-lang/crates.io-index"
261checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
262
263[[package]]
264name = "wasm-bindgen"
265version = "0.2.127"
266source = "registry+https://github.com/rust-lang/crates.io-index"
267checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
268dependencies = [
269 "cfg-if",
270 "once_cell",
271 "rustversion",
272 "wasm-bindgen-macro",
273 "wasm-bindgen-shared",
274]
275
276[[package]]
277name = "wasm-bindgen-macro"
278version = "0.2.127"
279source = "registry+https://github.com/rust-lang/crates.io-index"
280checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
281dependencies = [
282 "quote",
283 "wasm-bindgen-macro-support",
284]
285
286[[package]]
287name = "wasm-bindgen-macro-support"
288version = "0.2.127"
289source = "registry+https://github.com/rust-lang/crates.io-index"
290checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
291dependencies = [
292 "bumpalo",
293 "proc-macro2",
294 "quote",
295 "syn 2.0.119",
296 "wasm-bindgen-shared",
297]
298
299[[package]]
300name = "wasm-bindgen-shared"
301version = "0.2.127"
302source = "registry+https://github.com/rust-lang/crates.io-index"
303checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
304dependencies = [
305 "unicode-ident",
306]
Cargo.toml added +12
@@ -0,0 +1,12 @@
1[workspace]
2resolver = "2"
3members = [
4 "crates/signal-schema",
5 "crates/signald",
6 "crates/terminal-garden",
7]
8
9[workspace.package]
10version = "0.3.0"
11edition = "2021"
12license = "MIT"
README.md added +213
@@ -0,0 +1,213 @@
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 CI 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 in v0.3 as **`macos-collector/`**, a sibling **Swift** package (built
100with `swift build`, kept out of the cargo workspace — SwiftPM and cargo do not
101share a build system). It reads aggregate CPU load, battery %, charging, battery
102draw (W), and thermal state, and emits them as `signal-schema` wire frames — the
103same byte format `signald` parses. GPU/fan are deliberately omitted (no clean
104root-free IOKit channel). See `macos-collector/README.md` for the full **Swift ↔
105Rust wire contract** (the byte layout) and the shared canonical-frame test that
106pins both sides to the same bytes.
107
108## Workspace layout
109
110```text
111ambient-companions/
112├── Cargo.toml # Rust workspace
113├── crates/
114│ ├── signal-schema/ # shared wire format (the privacy boundary)
115│ │ ├── src/lib.rs
116│ │ └── tests/privacy_invariant.rs
117│ ├── signald/ # the daemon (collectors + live fan-out)
118│ │ ├── src/lib.rs # git + terminal collectors, publish
119│ │ ├── src/history.rs # SQLite (WAL) history store + recent() query
120│ │ ├── src/hub.rs # last-value cache + live fan-out
121│ │ ├── src/main.rs # CLI, producer loop, self-attestation
122│ │ ├── tests/git_collector.rs # aggregates vs a temp git repo
123│ │ ├── tests/streaming.rs # last-value cache + live update
124│ │ └── tests/differential_secret_typing.rs # the full privacy ship-gate
125│ └── terminal-garden/ # first renderer: a socket subscriber
126│ ├── src/lib.rs # signals → plots → render (unit-tested)
127│ └── src/main.rs # live subscribe + redraw loop
128├── shell-hooks/ # zsh hooks: aggregate-only terminal collector
129│ ├── signald-hooks.zsh
130│ └── README.md
131└── macos-collector/ # SwiftPM sibling (NOT in the cargo workspace):
132 ├── Package.swift # macOS IOKit hardware collector (Phase 3)
133 ├── README.md # the Swift↔Rust wire contract (byte layout)
134 ├── Sources/CollectorCore/ # wire encoder + IOKit reads (host_processor_
135 │ # info, power sources, AppleSmartBattery)
136 ├── Sources/macos-collector/ # thin CLI: --once / stream / --hex / --out
137 └── Tests/ # shared canonical-frame wire-contract test
138```
139
140### Language note
141
142The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen
143because it makes the "no content field exists" guarantee enforceable in the
144type system (the `f64`-only `Value` payload), which is the whole point of the
145privacy boundary.
146
147### Dependencies
148
149`signal-schema` is **dependency-free** by design — the wire format is the
150privacy boundary and carries no third-party code. `signald` has **one**
151dependency, `rusqlite` (with the `bundled` feature, so SQLite is compiled
152in-tree and there is no system-library requirement), for the WAL history store.
153The terminal collector's shell side needs only `zsh` (the `zle`, `datetime`,
154and — for the differential test — `zpty` modules ship with zsh).
155
156## Phase plan
157
158- **M0 — backend skeleton + schema + privacy invariant test. ✅ Done.**
159 Workspace builds; the structural privacy tests pass.
160- **Phase 1 — git collector + garden. ✅ Done (v0.1).**
161 The wire format (`encode`/`decode` + length-prefixed framing) is real and
162 `f64`-only. `signald`'s git collector shells out to `git` for aggregate
163 scalars — commits-in-window, commits-today, branch count, days-since-last-
164 commit — tagged by the audited repo path. `terminal-garden` subscribes and
165 renders each repo as a plot: growth (🌱→🌿→🌳) tracks recent commits, wilt
166 (🥀→🍂) tracks staleness.
167- **Persistence + live streaming. ✅ Done (v0.2).**
168 - **SQLite (WAL) history** (`signald/src/history.rs`): every published signal
169 is persisted (aggregate scalars only — same privacy constraints as the
170 wire), and `recent()` / `recent_named()` let a renderer read recent history,
171 not just the live snapshot.
172 - **Live streaming + last-value cache** (`signald/src/hub.rs`,
173 `publish::serve`): the daemon runs a producer loop and continuously
174 publishes. On connect a subscriber is handed the current value of every
175 metric immediately, then streams updates as they change (no longer
176 snapshot-then-close). `terminal-garden` now redraws live.
177- **Phase 2 — terminal collector. ✅ Done (v0.2).**
178 The aggregate-only terminal path is real: `shell-hooks/signald-hooks.zsh`
179 counts keystrokes with a `zle` widget that increments a number and discards
180 the key, and appends `<epoch_ms> <keys> <session_seconds>` count records
181 (numbers only) to a spool; `collectors::terminal` reads the spool and derives
182 `keys_per_min` and `session_seconds`. The **differential secret-typing test is
183 active and passing** — the privacy ship-gate. *The Terminal Pet renderer is
184 still todo.*
185- **Phase 3 — macOS IOKit hardware collector. ✅ Done (v0.3).**
186 `macos-collector/` (a sibling Swift package, `swift build`) reads aggregate
187 hardware scalars via **IOKit only — no `powermetrics`, no root**: CPU load
188 (Mach `host_processor_info`), battery %/charging (IOKit power sources), battery
189 draw in watts (IORegistry `AppleSmartBattery`), and thermal state
190 (`ProcessInfo`). It emits `signal-schema` wire frames — the schema gained the
191 aggregate `cpu_load` metric and bumped to `SCHEMA_VERSION = 3`. GPU/fan are
192 omitted (no clean root-free channel). The **Swift ↔ Rust byte contract** is
193 documented and pinned by a shared canonical-frame test on both sides
194 (`crates/signal-schema/tests/hardware_wire.rs` decodes the exact bytes the
195 Swift encoder commits to in `macos-collector/Tests/.../WireTests.swift`).
196 *Still todo:* the menu-bar permadeath pet, and the live socket handshake by
197 which `signald` ingests the collector's frames (minimal/documented in v0.3).
198- **Phase 4** — sonification (SSH-utility first, then continuous). *Out of scope.*
199- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
200 *Out of scope.*
201
202## Build & test
203
204```sh
205cargo build # whole Rust workspace
206cargo test # includes the privacy invariant suite (must be green)
207
208# The macOS IOKit hardware collector is a sibling Swift package (macOS only):
209cd macos-collector
210swift build # builds the collector
211swift test # the Swift↔Rust wire-contract test
212swift run macos-collector --once # one real IOKit read (no root)
213```
crates/signal-schema/Cargo.toml added +8
@@ -0,0 +1,8 @@
1[package]
2name = "signal-schema"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "Shared, versioned wire format for the ambient-companions signal bus. The privacy boundary is structural: the payload channel is an f64 and there is no content-carrying field."
7
8[dependencies]
crates/signal-schema/src/lib.rs added +465
@@ -0,0 +1,465 @@
1//! # signal-schema
2//!
3//! The shared, versioned wire format for the ambient-companions signal bus.
4//!
5//! ## The privacy boundary, made structural
6//!
7//! Everything in this suite is a footnote to one invariant (spec §1.5):
8//!
9//! > No process persists, transmits, or exposes any representation from which
10//! > the content or identity of an individual keystroke, command argument, or
11//! > typed character can be recovered. Only order-free aggregates (counts,
12//! > rates, durations, codes) leave the terminal collector.
13//!
14//! This crate makes the *schema-level half* of that invariant true **by
15//! construction**: the only payload channel is [`Value`], a newtype over
16//! `f64`. There is no `text`, `bytes`, `payload`, or `content` field. A
17//! collector *cannot* emit typed content because the wire format has nowhere
18//! to put it.
19//!
20//! The two audited exceptions are non-content identifiers carried in [`Tag`]
21//! (a bundle id, an absolute repo path, or an SSH host) and are allow-listed
22//! per [`SignalName`]. Everything else is `tag == None`, enforced at the
23//! daemon boundary.
24//!
25//! The structural guarantee is not asserted in prose alone — see
26//! `tests/privacy_invariant.rs`, which fails the build if a content-carrying
27//! field is ever added.
28
29/// Bump on **any** field change to [`Signal`]. Renderers negotiate on connect
30/// and ignore records they do not understand.
31///
32/// v3 (0.3.0): added the aggregate [`SignalName::CpuLoad`] emitted by the macOS
33/// IOKit hardware collector (`macos-collector/`, a sibling Swift package).
34pub const SCHEMA_VERSION: u16 = 3;
35
36/// The collector domain a signal originated from.
37///
38/// A small closed enum — never free text.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Source {
41 Terminal,
42 Git,
43 Macos,
44 Hardware,
45}
46
47impl Source {
48 /// Stable wire discriminant.
49 pub fn to_u8(self) -> u8 {
50 match self {
51 Source::Terminal => 0,
52 Source::Git => 1,
53 Source::Macos => 2,
54 Source::Hardware => 3,
55 }
56 }
57
58 /// Inverse of [`Source::to_u8`]; `None` for an unknown discriminant.
59 pub fn from_u8(v: u8) -> Option<Source> {
60 Some(match v {
61 0 => Source::Terminal,
62 1 => Source::Git,
63 2 => Source::Macos,
64 3 => Source::Hardware,
65 _ => return None,
66 })
67 }
68}
69
70/// The **enum-constrained** metric name.
71///
72/// A fixed allow-list. Unknown names are dropped at the daemon boundary. This
73/// prevents a future careless collector from inventing `last_command` as a
74/// name and shoving a string through the tag. New metrics require a new
75/// variant here (and a schema-version bump), which is a reviewed change.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SignalName {
78 // --- terminal (aggregates only) ---
79 KeysPerMin,
80 InterArrivalVariance,
81 SessionSeconds,
82 CommandsPerMin,
83 ExitCodeRatio,
84 TodBucket,
85 // --- git ---
86 Commits5m,
87 LinesAdded,
88 LinesRemoved,
89 BranchLastCommitAge,
90 DirtyWorktree,
91 /// Commits within the collector's rolling window (last N days).
92 CommitsWindow,
93 /// Commits since local midnight.
94 CommitsToday,
95 /// Number of local branches in the repo.
96 BranchCount,
97 /// Whole days since the most recent commit on `HEAD`.
98 DaysSinceLastCommit,
99 // --- macos system ---
100 AppForegroundSeconds,
101 IdleSeconds,
102 BatteryPct,
103 Charging,
104 ThermalState,
105 // --- hardware ---
106 /// Aggregate CPU busy fraction across all cores in `[0.0, 1.0]`, from Mach
107 /// `host_processor_info` tick deltas. Emitted by the macOS IOKit collector
108 /// as an untagged scalar (the per-core, tagged variant is [`CpuCoreLoad`]).
109 CpuLoad,
110 CpuCoreLoad,
111 NetRxBps,
112 NetTxBps,
113 FanRpm,
114 DiskReadBps,
115 DiskWriteBps,
116 GpuUtil,
117 BatteryDrawW,
118}
119
120impl SignalName {
121 /// Whether this metric is permitted to carry a [`Tag`]. Only a small,
122 /// audited set may — everything else must have `tag == None`.
123 pub fn allows_tag(self) -> bool {
124 matches!(
125 self,
126 SignalName::BranchLastCommitAge // tag: repo/branch identifier
127 | SignalName::CommitsWindow // tag: repo path (audited, spec §1.2)
128 | SignalName::CommitsToday // tag: repo path
129 | SignalName::BranchCount // tag: repo path
130 | SignalName::DaysSinceLastCommit // tag: repo path
131 | SignalName::AppForegroundSeconds // tag: bundle id
132 | SignalName::CpuCoreLoad // tag: core index
133 )
134 }
135
136 /// Stable wire discriminant. New metrics append here (a reviewed change
137 /// that also bumps [`SCHEMA_VERSION`]).
138 pub fn to_u8(self) -> u8 {
139 match self {
140 SignalName::KeysPerMin => 0,
141 SignalName::InterArrivalVariance => 1,
142 SignalName::SessionSeconds => 2,
143 SignalName::CommandsPerMin => 3,
144 SignalName::ExitCodeRatio => 4,
145 SignalName::TodBucket => 5,
146 SignalName::Commits5m => 6,
147 SignalName::LinesAdded => 7,
148 SignalName::LinesRemoved => 8,
149 SignalName::BranchLastCommitAge => 9,
150 SignalName::DirtyWorktree => 10,
151 SignalName::CommitsWindow => 11,
152 SignalName::CommitsToday => 12,
153 SignalName::BranchCount => 13,
154 SignalName::DaysSinceLastCommit => 14,
155 SignalName::AppForegroundSeconds => 15,
156 SignalName::IdleSeconds => 16,
157 SignalName::BatteryPct => 17,
158 SignalName::Charging => 18,
159 SignalName::ThermalState => 19,
160 SignalName::CpuCoreLoad => 20,
161 SignalName::NetRxBps => 21,
162 SignalName::NetTxBps => 22,
163 SignalName::FanRpm => 23,
164 SignalName::DiskReadBps => 24,
165 SignalName::DiskWriteBps => 25,
166 SignalName::GpuUtil => 26,
167 SignalName::BatteryDrawW => 27,
168 // Appended in v3 (0.3.0); discriminants are append-only.
169 SignalName::CpuLoad => 28,
170 }
171 }
172
173 /// Inverse of [`SignalName::to_u8`]. An unknown discriminant returns `None`
174 /// so the daemon can drop names it does not understand at the boundary.
175 pub fn from_u8(v: u8) -> Option<SignalName> {
176 Some(match v {
177 0 => SignalName::KeysPerMin,
178 1 => SignalName::InterArrivalVariance,
179 2 => SignalName::SessionSeconds,
180 3 => SignalName::CommandsPerMin,
181 4 => SignalName::ExitCodeRatio,
182 5 => SignalName::TodBucket,
183 6 => SignalName::Commits5m,
184 7 => SignalName::LinesAdded,
185 8 => SignalName::LinesRemoved,
186 9 => SignalName::BranchLastCommitAge,
187 10 => SignalName::DirtyWorktree,
188 11 => SignalName::CommitsWindow,
189 12 => SignalName::CommitsToday,
190 13 => SignalName::BranchCount,
191 14 => SignalName::DaysSinceLastCommit,
192 15 => SignalName::AppForegroundSeconds,
193 16 => SignalName::IdleSeconds,
194 17 => SignalName::BatteryPct,
195 18 => SignalName::Charging,
196 19 => SignalName::ThermalState,
197 20 => SignalName::CpuCoreLoad,
198 21 => SignalName::NetRxBps,
199 22 => SignalName::NetTxBps,
200 23 => SignalName::FanRpm,
201 24 => SignalName::DiskReadBps,
202 25 => SignalName::DiskWriteBps,
203 26 => SignalName::GpuUtil,
204 27 => SignalName::BatteryDrawW,
205 28 => SignalName::CpuLoad,
206 _ => return None,
207 })
208 }
209}
210
211/// The **only** payload channel: a single `f64`.
212///
213/// This newtype is the load-bearing privacy primitive. There is deliberately
214/// no constructor that accepts a `String`, `&[u8]`, or any content-shaped
215/// type. If you find yourself wanting to widen this, stop: that is the
216/// privacy boundary you would be dismantling.
217#[derive(Debug, Clone, Copy, PartialEq)]
218pub struct Value(pub f64);
219
220/// A structurally validated, non-content identifier (spec §1.2).
221///
222/// Never free text. Constructed only via [`Tag::bundle_id`],
223/// [`Tag::repo_path`], or [`Tag::ssh_host`], each of which validates shape.
224/// M0: validation is stubbed.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct Tag(String);
227
228impl Tag {
229 /// A macOS bundle id, e.g. `com.apple.Terminal`. Validated against a
230 /// bundle-id shape at the daemon boundary.
231 pub fn bundle_id(_s: &str) -> Option<Tag> {
232 todo!("validate bundle-id shape (reverse-DNS); reject anything else")
233 }
234
235 /// An absolute repo path. Validated as a shape (absolute, non-empty, no
236 /// interior NUL) — a structural identifier, never free text. Per-watched-
237 /// root prefix enforcement is layered on at the daemon boundary later.
238 pub fn repo_path(s: &str) -> Option<Tag> {
239 if s.starts_with('/') && !s.is_empty() && !s.contains('\0') {
240 Some(Tag(s.to_string()))
241 } else {
242 None
243 }
244 }
245
246 /// An SSH host present in `known_hosts`.
247 pub fn ssh_host(_s: &str) -> Option<Tag> {
248 todo!("validate against ~/.ssh/known_hosts")
249 }
250
251 /// Read-only view of the validated identifier.
252 pub fn as_str(&self) -> &str {
253 &self.0
254 }
255}
256
257/// One flat, versioned record. Every field is a named scalar, a small enum, or
258/// an audited non-content identifier.
259///
260/// Note what is absent: there is no field capable of carrying a character or a
261/// string of typed content. That absence is the point.
262#[derive(Debug, Clone, PartialEq)]
263pub struct Signal {
264 /// Bump on any field change; see [`SCHEMA_VERSION`].
265 pub schema_version: u16,
266 /// Unix millis, monotonic-corrected.
267 pub ts: u64,
268 /// Originating collector domain.
269 pub source: Source,
270 /// Enum-constrained metric name.
271 pub name: SignalName,
272 /// The only payload channel.
273 pub value: Value,
274 /// Optional, audited, non-content identifier. `Some` only for names where
275 /// [`SignalName::allows_tag`] is true.
276 pub tag: Option<Tag>,
277}
278
279impl Signal {
280 /// Validate a record against the schema's structural rules: a tag is
281 /// present only where the name allows it. This is the daemon-boundary
282 /// check; renderers can trust records that pass it.
283 pub fn is_well_formed(&self) -> bool {
284 self.schema_version == SCHEMA_VERSION
285 && (self.tag.is_none() || self.name.allows_tag())
286 }
287}
288
289/// Length-prefixed wire encoding (spec §1.3: "length-prefixed framing over the
290/// Unix socket").
291///
292/// The frame is a little-endian `u32` body length followed by the body:
293///
294/// ```text
295/// [u32 body_len] [u16 schema_version] [u64 ts] [u8 source] [u8 name]
296/// [f64 value] [u8 tag_present] [ (u16 tag_len) (tag_len bytes utf8) ]?
297/// ```
298///
299/// The payload channel is still exactly the `f64` `value` — the framing adds
300/// nowhere to put typed content. The one string on the wire is the audited
301/// `tag` identifier, and only when [`SignalName::allows_tag`] permits it.
302pub mod wire {
303 use super::{Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION};
304
305 /// Encode a signal to its length-prefixed wire bytes.
306 pub fn encode(s: &Signal) -> Vec<u8> {
307 let mut body = Vec::with_capacity(24);
308 body.extend_from_slice(&s.schema_version.to_le_bytes());
309 body.extend_from_slice(&s.ts.to_le_bytes());
310 body.push(s.source.to_u8());
311 body.push(s.name.to_u8());
312 body.extend_from_slice(&s.value.0.to_le_bytes());
313 match &s.tag {
314 None => body.push(0),
315 Some(tag) => {
316 body.push(1);
317 let bytes = tag.as_str().as_bytes();
318 body.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
319 body.extend_from_slice(bytes);
320 }
321 }
322
323 let mut frame = Vec::with_capacity(4 + body.len());
324 frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
325 frame.extend_from_slice(&body);
326 frame
327 }
328
329 /// Decode one length-prefixed frame from the front of `buf`. Returns `None`
330 /// if the buffer is short or the frame is malformed. Trailing bytes are
331 /// ignored, so this is safe to call on a read buffer holding one frame.
332 pub fn decode(buf: &[u8]) -> Option<Signal> {
333 if buf.len() < 4 {
334 return None;
335 }
336 let body_len = u32::from_le_bytes(buf[0..4].try_into().ok()?) as usize;
337 let body = buf.get(4..4 + body_len)?;
338 decode_body(body)
339 }
340
341 /// Read exactly one frame from a stream. `Ok(None)` signals a clean EOF at a
342 /// frame boundary; a partial or malformed frame is an error.
343 pub fn read_frame(r: &mut impl std::io::Read) -> std::io::Result<Option<Signal>> {
344 use std::io::{Error, ErrorKind};
345
346 let mut len_buf = [0u8; 4];
347 match r.read_exact(&mut len_buf) {
348 Ok(()) => {}
349 Err(e) if e.kind() == ErrorKind::UnexpectedEof => return Ok(None),
350 Err(e) => return Err(e),
351 }
352 let body_len = u32::from_le_bytes(len_buf) as usize;
353 let mut body = vec![0u8; body_len];
354 r.read_exact(&mut body)?;
355 decode_body(&body)
356 .map(Some)
357 .ok_or_else(|| Error::new(ErrorKind::InvalidData, "malformed signal frame"))
358 }
359
360 /// Encode `s` and write the whole frame to a stream.
361 pub fn write_frame(w: &mut impl std::io::Write, s: &Signal) -> std::io::Result<()> {
362 w.write_all(&encode(s))
363 }
364
365 fn decode_body(body: &[u8]) -> Option<Signal> {
366 // schema_version(2) + ts(8) + source(1) + name(1) + value(8) + tag_flag(1)
367 if body.len() < 21 {
368 return None;
369 }
370 let schema_version = u16::from_le_bytes(body[0..2].try_into().ok()?);
371 let ts = u64::from_le_bytes(body[2..10].try_into().ok()?);
372 let source = Source::from_u8(body[10])?;
373 let name = SignalName::from_u8(body[11])?;
374 let value = Value(f64::from_le_bytes(body[12..20].try_into().ok()?));
375
376 let tag = match body[20] {
377 0 => None,
378 1 => {
379 let len = u16::from_le_bytes(body.get(21..23)?.try_into().ok()?) as usize;
380 let bytes = body.get(23..23 + len)?;
381 Some(Tag(std::str::from_utf8(bytes).ok()?.to_string()))
382 }
383 _ => return None,
384 };
385
386 let signal = Signal {
387 schema_version,
388 ts,
389 source,
390 name,
391 value,
392 tag,
393 };
394 // Only accept records this build understands and that obey the tag rule.
395 if signal.schema_version != SCHEMA_VERSION || !signal.is_well_formed() {
396 return None;
397 }
398 Some(signal)
399 }
400}
401
402#[cfg(test)]
403mod wire_tests {
404 use super::*;
405
406 fn sig(name: SignalName, value: f64, tag: Option<Tag>) -> Signal {
407 Signal {
408 schema_version: SCHEMA_VERSION,
409 ts: 1_723_100_000_000,
410 source: Source::Git,
411 name,
412 value: Value(value),
413 tag,
414 }
415 }
416
417 #[test]
418 fn round_trip_no_tag() {
419 let s = sig(SignalName::CommitsToday, 7.0, None);
420 let bytes = wire::encode(&s);
421 let back = wire::decode(&bytes).expect("decodes");
422 assert_eq!(s, back);
423 }
424
425 #[test]
426 fn round_trip_with_tag() {
427 let tag = Tag::repo_path("/Users/x/git/repo").expect("valid repo path");
428 let s = Signal {
429 source: Source::Git,
430 ..sig(SignalName::BranchLastCommitAge, 3.0, Some(tag))
431 };
432 let bytes = wire::encode(&s);
433 let back = wire::decode(&bytes).expect("decodes");
434 assert_eq!(s, back);
435 }
436
437 #[test]
438 fn round_trip_preserves_float_payload() {
439 let s = sig(SignalName::DaysSinceLastCommit, 12.5, None);
440 let back = wire::decode(&wire::encode(&s)).expect("decodes");
441 assert_eq!(back.value, Value(12.5));
442 }
443
444 #[test]
445 fn stream_read_frame_round_trips_multiple() {
446 let a = sig(SignalName::CommitsWindow, 4.0, None);
447 let b = sig(SignalName::BranchCount, 2.0, None);
448 let mut buf = Vec::new();
449 wire::write_frame(&mut buf, &a).unwrap();
450 wire::write_frame(&mut buf, &b).unwrap();
451
452 let mut cursor = std::io::Cursor::new(buf);
453 let ra = wire::read_frame(&mut cursor).unwrap().unwrap();
454 let rb = wire::read_frame(&mut cursor).unwrap().unwrap();
455 let end = wire::read_frame(&mut cursor).unwrap();
456 assert_eq!(ra, a);
457 assert_eq!(rb, b);
458 assert!(end.is_none(), "clean EOF at frame boundary");
459 }
460
461 #[test]
462 fn decode_rejects_short_buffer() {
463 assert!(wire::decode(&[0, 1, 2]).is_none());
464 }
465}
crates/signal-schema/tests/hardware_wire.rs added +85
@@ -0,0 +1,85 @@
1//! # The Swift↔Rust hardware wire contract (v0.3)
2//!
3//! The macOS IOKit collector is a sibling Swift package (`macos-collector/`)
4//! that speaks this crate's wire format. Swift and Rust are two independent
5//! implementations of the same byte layout, so the contract needs a test that
6//! pins the *exact bytes* both sides must agree on — not just a Rust round trip.
7//!
8//! This test hand-encodes, per the documented frame layout
9//! (`signal_schema::wire`), the canonical hardware frame the Swift collector
10//! emits, and asserts:
11//! 1. `wire::decode` parses those exact bytes into the expected [`Signal`]
12//! (this is signald's parse path — signald decodes hardware frames with the
13//! same function), and
14//! 2. `wire::encode` reproduces those exact bytes from the [`Signal`].
15//!
16//! The identical byte array is asserted on the Swift side in
17//! `macos-collector/Tests/CollectorCoreTests/WireTests.swift`, so both languages
18//! independently commit to the same wire. Cross-*process* execution of the Swift
19//! binary is not required to prove agreement — the shared literal is the proof.
20//!
21//! Privacy note: a hardware frame carries the same `f64`-only payload as every
22//! other signal. `CpuLoad` is untagged (`tag_present == 0`); there is no content
23//! field. The privacy invariant is intact for hardware signals by construction.
24
25use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION};
26
27/// The canonical hardware test vector, shared verbatim with the Swift side.
28///
29/// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28),
30/// value=0.5 (`f64`), tag=None.
31///
32/// Frame bytes (little-endian throughout):
33/// ```text
34/// 15 00 00 00 body_len = 21 (u32)
35/// 03 00 schema_version = 3 (u16)
36/// 00 00 00 00 00 00 00 00 ts = 0 (u64)
37/// 03 source = Hardware (u8)
38/// 1C name = CpuLoad = 28 (u8)
39/// 00 00 00 00 00 00 E0 3F value = 0.5 (f64)
40/// 00 tag_present = 0 (u8)
41/// ```
42const CANONICAL_FRAME: [u8; 25] = [
43 0x15, 0x00, 0x00, 0x00, // body_len = 21
44 0x03, 0x00, // schema_version = 3
45 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0
46 0x03, // source = Hardware
47 0x1C, // name = CpuLoad (28)
48 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5
49 0x00, // tag_present = 0
50];
51
52fn canonical_signal() -> Signal {
53 Signal {
54 schema_version: SCHEMA_VERSION,
55 ts: 0,
56 source: Source::Hardware,
57 name: SignalName::CpuLoad,
58 value: Value(0.5),
59 tag: None,
60 }
61}
62
63/// signald parses a hardware frame produced to the documented byte layout.
64#[test]
65fn signald_decodes_the_swift_hardware_frame() {
66 let decoded = wire::decode(&CANONICAL_FRAME).expect("signald must decode the hardware frame");
67 assert_eq!(decoded, canonical_signal());
68 // The privacy boundary holds for hardware signals too: f64 payload, no tag.
69 assert_eq!(decoded.value, Value(0.5));
70 assert!(decoded.tag.is_none(), "aggregate hardware scalar carries no tag");
71}
72
73/// The Rust encoder reproduces the exact bytes the Swift encoder commits to.
74#[test]
75fn rust_encoder_matches_the_shared_byte_vector() {
76 assert_eq!(wire::encode(&canonical_signal()), CANONICAL_FRAME.to_vec());
77}
78
79/// The new aggregate CPU metric is untagged and stable at discriminant 28.
80#[test]
81fn cpu_load_is_untagged_and_stable() {
82 assert_eq!(SignalName::CpuLoad.to_u8(), 28);
83 assert_eq!(SignalName::from_u8(28), Some(SignalName::CpuLoad));
84 assert!(!SignalName::CpuLoad.allows_tag(), "aggregate CPU load is never tagged");
85}
crates/signal-schema/tests/privacy_invariant.rs added +309
@@ -0,0 +1,309 @@
1//! # The privacy invariant test (spec §1.5)
2//!
3//! This is the single most important test in the project. It exists from day
4//! one, before the sensitive terminal collector does, so the guardrail is in
5//! place before the code it guards.
6//!
7//! The invariant (spec §1.5):
8//!
9//! > No process persists, transmits, or exposes any representation from which
10//! > the content or identity of an individual keystroke, command argument, or
11//! > typed character can be recovered. Only order-free aggregates leave the
12//! > terminal collector.
13//!
14//! At M0 we encode the *structural* half as real, passing tests:
15//! 1. `value_channel_is_exactly_f64` — the payload is an `f64`, nothing wider.
16//! 2. `wire_format_has_no_content_field` — the `Signal` type declares no
17//! content-carrying field (only `tag: Option<Tag>` is a string, and it is
18//! the audited identifier exception).
19//! 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.
26
27use std::mem::size_of;
28use std::path::PathBuf;
29
30use signal_schema::Value;
31
32/// The payload channel is exactly an `f64` — no room for a character, a string,
33/// or a byte buffer. This is the load-bearing structural guarantee.
34#[test]
35fn value_channel_is_exactly_f64() {
36 assert_eq!(
37 size_of::<Value>(),
38 size_of::<f64>(),
39 "Value must be a transparent f64 payload; anything wider is a content channel"
40 );
41 // Construction only accepts an f64. If someone adds a String-accepting
42 // constructor, this file is where the review happens.
43 let _v = Value(3.14_f64);
44}
45
46/// The `Signal` type must declare no content-carrying field. We assert this
47/// structurally by scanning the schema source: within the `struct Signal`
48/// declaration, the only `String`-typed payload permitted is the audited
49/// `tag`. Any `content` / `text` / `bytes` / `payload` field fails the build.
50#[test]
51fn wire_format_has_no_content_field() {
52 let lib = repo_root().join("crates/signal-schema/src/lib.rs");
53 let src = std::fs::read_to_string(&lib).expect("read signal-schema/src/lib.rs");
54
55 let start = src.find("pub struct Signal {").expect("Signal struct present");
56 let body = &src[start..];
57 let end = body.find('}').expect("Signal struct closes");
58 // Field declarations only — strip doc/comment lines so prose like
59 // "non-content identifier" doesn't false-positive.
60 let body: String = body[..end]
61 .lines()
62 .filter(|l| !l.trim_start().starts_with("//"))
63 .collect::<Vec<_>>()
64 .join("\n");
65
66 for banned in ["content", "text", "bytes", "payload", "keystroke", "command"] {
67 assert!(
68 !body.contains(banned),
69 "Signal declares a forbidden content field containing `{banned}`; \
70 the wire format must have nowhere to put typed content"
71 );
72 }
73 // The one audited string exception is the identifier tag.
74 assert!(
75 body.contains("tag: Option<Tag>"),
76 "the only string in Signal must be the audited `tag` identifier"
77 );
78}
79
80/// The static CI gate (spec §1.5): the tree must contain none of the APIs a
81/// real keylogger would use, nor any shell hook that touches the line buffer.
82/// Any hit fails the build. The banned list itself lives here and is reviewed.
83#[test]
84fn forbidden_symbol_scan() {
85 // APIs for input taps / global key monitoring, and shell line-buffer refs.
86 const BANNED: &[&str] = &[
87 "CGEventTap",
88 "IOHIDManager",
89 "kAXTrusted",
90 "addGlobalMonitorForEvents",
91 "$BUFFER",
92 "$LBUFFER",
93 ];
94 const CODE_EXTS: &[&str] = &["rs", "zsh", "sh", "swift", "m", "c", "h"];
95
96 let this_file = PathBuf::from(file!());
97 let this_name = this_file.file_name().unwrap();
98
99 let mut offenders = Vec::new();
100 for path in walk(&repo_root()) {
101 // Skip build artifacts and this test (which names the banned tokens).
102 // `target` is Rust's; `.build` is SwiftPM's (macos-collector/.build).
103 if path
104 .components()
105 .any(|c| matches!(c.as_os_str().to_str(), Some("target") | Some(".build")))
106 {
107 continue;
108 }
109 if path.file_name() == Some(this_name) {
110 continue;
111 }
112 let ext_ok = path
113 .extension()
114 .and_then(|e| e.to_str())
115 .map(|e| CODE_EXTS.contains(&e))
116 .unwrap_or(false);
117 if !ext_ok {
118 continue;
119 }
120 let Ok(text) = std::fs::read_to_string(&path) else {
121 continue;
122 };
123 for banned in BANNED {
124 if text.contains(banned) {
125 offenders.push(format!("{}: {banned}", path.display()));
126 }
127 }
128 }
129
130 assert!(
131 offenders.is_empty(),
132 "forbidden keylogger symbol(s) found:\n{}",
133 offenders.join("\n")
134 );
135}
136
137/// The differential secret-typing acceptance test (spec §1.5, §5 Phase 2) —
138/// the ship gate for the terminal collector, now **active**.
139///
140/// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`)
141/// through a **real interactive zsh under a real pseudo-terminal** (zsh's own
142/// `zsh/zpty` module — no external dependency), *typing a planted secret* the
143/// way a person would at a prompt. Real `zle` keystroke counting increments a
144/// number per key and discards the key, so the only thing the shell emits is a
145/// count. This test proves that empirically: the secret must never appear —
146/// plain, reversed, hex, or base64 — in the spool the shell writes, nor in the
147/// `f64`-only wire encoding of the signals derived from those counts.
148///
149/// This crate is the privacy boundary and stays dependency-free, so it exercises
150/// the schema-reachable half (spool + wire). The **full pipeline** gate — the
151/// same real typing driven through the terminal collector, the SQLite history
152/// store, and the hub — lives in `crates/signald/tests/differential_secret_typing.rs`
153/// (that crate owns the collector and the store).
154#[test]
155fn differential_secret_typing() {
156 use std::process::Command;
157
158 // A distinctive, non-dictionary secret so an incidental byte-collision is
159 // not credible.
160 const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET";
161
162 let root = repo_root();
163 let hooks = root.join("shell-hooks/signald-hooks.zsh");
164 assert!(hooks.exists(), "production hook missing at {}", hooks.display());
165
166 let nanos = std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .unwrap()
169 .as_nanos();
170 let tmp = std::env::temp_dir().join(format!("ambient-diff-schema-{}-{nanos}", std::process::id()));
171 std::fs::create_dir_all(&tmp).unwrap();
172 let spool = tmp.join("terminal.spool");
173 let driver = tmp.join("driver.zsh");
174 std::fs::write(&spool, b"").unwrap();
175
176 // pty driver: spawn interactive zsh under zpty, source the real hook, and
177 // type the secret as ordinary `echo` arguments — real keystrokes, real zle.
178 const DRIVER: &str = r#"
179zmodload zsh/zpty || exit 3
180zpty SH zsh -f -i || exit 4
181drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done }
182sleep 0.4; drain
183zpty -w SH "source $HOOKS"
184sleep 0.3; drain
185zpty -w SH "echo $SECRET"
186sleep 0.5; drain
187zpty -w SH "echo typed $SECRET twice $SECRET"
188sleep 0.5; drain
189zpty -w SH "exit"
190sleep 0.3
191zpty -d SH 2>/dev/null
192"#;
193 std::fs::write(&driver, DRIVER).unwrap();
194
195 let status = Command::new("zsh")
196 .arg("-f")
197 .arg(&driver)
198 .env("SIGNALD_SPOOL", &spool)
199 .env("HOOKS", &hooks)
200 .env("SECRET", SECRET)
201 .status()
202 .expect("run zsh/zpty harness (the privacy ship-gate must run)");
203 assert!(status.success(), "zsh/zpty harness failed");
204
205 let spool_bytes = std::fs::read(&spool).unwrap();
206 let _ = std::fs::remove_dir_all(&tmp);
207
208 // Parse count records (three integers per line) and confirm the typing was
209 // genuinely counted — otherwise we'd be "proving" absence over an empty run.
210 let mut max_keys = 0u64;
211 let mut wire_bytes: Vec<u8> = Vec::new();
212 for line in String::from_utf8_lossy(&spool_bytes).lines() {
213 let nums: Vec<u64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
214 if nums.len() != 3 {
215 continue; // not a well-formed count record
216 }
217 max_keys = max_keys.max(nums[1]);
218 // Build a signal from the count alone — the only thing available — and
219 // encode it to the real wire format.
220 let sig = signal_schema::Signal {
221 schema_version: signal_schema::SCHEMA_VERSION,
222 ts: nums[0],
223 source: signal_schema::Source::Terminal,
224 name: signal_schema::SignalName::KeysPerMin,
225 value: Value(nums[1] as f64),
226 tag: None,
227 };
228 wire_bytes.extend_from_slice(&signal_schema::wire::encode(&sig));
229 }
230 assert!(
231 max_keys >= SECRET.chars().count() as u64,
232 "secret does not appear to have been typed through the hook \
233 (max keys counted = {max_keys}, secret len = {})",
234 SECRET.chars().count()
235 );
236
237 // The secret must be absent from the spool and the wire, in any encoding.
238 let b = SECRET.as_bytes();
239 let reversed: Vec<u8> = b.iter().rev().copied().collect();
240 let needles: [(&str, Vec<u8>); 4] = [
241 ("plain", b.to_vec()),
242 ("reversed", reversed),
243 ("hex", to_hex(b).into_bytes()),
244 ("base64", to_base64(b).into_bytes()),
245 ];
246 for (label, artifact) in [("spool", &spool_bytes), ("wire", &wire_bytes)] {
247 for (enc, needle) in &needles {
248 assert!(
249 !byte_contains(artifact, needle),
250 "SECRET LEAK: found the secret ({enc}) in the {label} — only \
251 aggregate counts may leave the terminal collector"
252 );
253 }
254 }
255}
256
257fn byte_contains(hay: &[u8], needle: &[u8]) -> bool {
258 !needle.is_empty() && hay.len() >= needle.len() && hay.windows(needle.len()).any(|w| w == needle)
259}
260
261fn to_hex(bytes: &[u8]) -> String {
262 let mut s = String::with_capacity(bytes.len() * 2);
263 for byte in bytes {
264 s.push_str(&format!("{byte:02x}"));
265 }
266 s
267}
268
269fn to_base64(bytes: &[u8]) -> String {
270 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
271 let mut out = String::new();
272 for chunk in bytes.chunks(3) {
273 let b0 = chunk[0] as u32;
274 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
275 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
276 let n = (b0 << 16) | (b1 << 8) | b2;
277 out.push(T[(n >> 18 & 63) as usize] as char);
278 out.push(T[(n >> 12 & 63) as usize] as char);
279 out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
280 out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
281 }
282 out
283}
284
285// --- helpers ---
286
287fn repo_root() -> PathBuf {
288 // CARGO_MANIFEST_DIR = <root>/crates/signal-schema
289 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
290 .join("../..")
291 .canonicalize()
292 .expect("canonicalize repo root")
293}
294
295fn walk(dir: &PathBuf) -> Vec<PathBuf> {
296 let mut out = Vec::new();
297 let Ok(entries) = std::fs::read_dir(dir) else {
298 return out;
299 };
300 for entry in entries.flatten() {
301 let path = entry.path();
302 if path.is_dir() {
303 out.extend(walk(&path));
304 } else {
305 out.push(path);
306 }
307 }
308 out
309}
crates/signald/Cargo.toml added +17
@@ -0,0 +1,17 @@
1[package]
2name = "signald"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "The ambient-companions signal-collection daemon: owns all collectors, normalizes to the shared schema, and publishes a pub/sub stream over a local Unix socket. One backend, five faces."
7
8[[bin]]
9name = "signald"
10path = "src/main.rs"
11
12[dependencies]
13signal-schema = { path = "../signal-schema" }
14# History store (spec §1.3): SQLite in WAL mode. `bundled` compiles SQLite in-
15# tree so there is no system-library dependency. This is the one new dependency
16# added in v0.2; signal-schema stays dependency-free by design.
17rusqlite = { version = "0.40", features = ["bundled"] }
crates/signald/src/history.rs added +176
@@ -0,0 +1,176 @@
1//! # History store (spec §1.3, §4)
2//!
3//! A local SQLite (WAL) store of a rolling history of signals. History is what
4//! lets a renderer read *recent aggregates* rather than only a live snapshot —
5//! it is what lets the garden survive a restart and (later) the aging pet know
6//! its true age.
7//!
8//! ## Same privacy constraints as the wire
9//!
10//! The store holds **aggregate scalars only**. Each row carries the schema
11//! discriminants, the `f64` `value`, and the audited non-content `tag` — exactly
12//! the fields of a [`Signal`], nothing wider. There is no content column. As a
13//! belt-and-braces measure the audited wire frame is also stored as a BLOB and
14//! is the source of truth on read, so reconstruction reuses the same validated
15//! [`wire::decode`] the socket uses — a row can decode to nothing but a
16//! well-formed, `f64`-only signal. The differential secret-typing test writes a
17//! planted secret through the collector into this store and asserts it never
18//! appears here in any encoding.
19
20use std::path::Path;
21
22use rusqlite::{params, Connection};
23use signal_schema::{wire, Signal};
24
25/// A handle to the SQLite history database.
26pub struct History {
27 conn: Connection,
28}
29
30impl History {
31 /// Open (creating if needed) the history database at `path` in WAL mode.
32 pub fn open(path: &Path) -> rusqlite::Result<History> {
33 let conn = Connection::open(path)?;
34 // WAL: concurrent reads while the daemon writes; survives restarts.
35 conn.pragma_update(None, "journal_mode", "WAL")?;
36 Self::init(conn)
37 }
38
39 /// An ephemeral in-memory store (used by tests).
40 pub fn open_in_memory() -> rusqlite::Result<History> {
41 Self::init(Connection::open_in_memory()?)
42 }
43
44 fn init(conn: Connection) -> rusqlite::Result<History> {
45 // Columns for querying (name, ts, value, tag); `frame` is the audited
46 // wire encoding, reused for faithful, validated reconstruction on read.
47 conn.execute(
48 "CREATE TABLE IF NOT EXISTS signals (
49 id INTEGER PRIMARY KEY AUTOINCREMENT,
50 schema_version INTEGER NOT NULL,
51 ts INTEGER NOT NULL,
52 source INTEGER NOT NULL,
53 name INTEGER NOT NULL,
54 value REAL NOT NULL,
55 tag TEXT,
56 frame BLOB NOT NULL
57 )",
58 [],
59 )?;
60 conn.execute(
61 "CREATE INDEX IF NOT EXISTS idx_signals_name_ts ON signals(name, ts)",
62 [],
63 )?;
64 Ok(History { conn })
65 }
66
67 /// Append one signal to the history.
68 pub fn record(&self, s: &Signal) -> rusqlite::Result<()> {
69 self.conn.execute(
70 "INSERT INTO signals (schema_version, ts, source, name, value, tag, frame)
71 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
72 params![
73 s.schema_version,
74 s.ts as i64, // SQLite integers are i64; unix-millis fits
75 s.source.to_u8(),
76 s.name.to_u8(),
77 s.value.0,
78 s.tag.as_ref().map(|t| t.as_str()),
79 wire::encode(s),
80 ],
81 )?;
82 Ok(())
83 }
84
85 /// The most recent `limit` signals, newest first. This is the query that
86 /// lets a renderer read recent history instead of only the live snapshot.
87 pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<Signal>> {
88 self.query("SELECT frame FROM signals ORDER BY id DESC LIMIT ?1", limit)
89 }
90
91 /// The most recent `limit` signals for one metric name, newest first.
92 pub fn recent_named(
93 &self,
94 name: signal_schema::SignalName,
95 limit: usize,
96 ) -> rusqlite::Result<Vec<Signal>> {
97 let mut stmt = self.conn.prepare(
98 "SELECT frame FROM signals WHERE name = ?1 ORDER BY id DESC LIMIT ?2",
99 )?;
100 let rows = stmt.query_map(params![name.to_u8(), limit as i64], |row| {
101 row.get::<_, Vec<u8>>(0)
102 })?;
103 Self::decode_rows(rows)
104 }
105
106 fn query(&self, sql: &str, limit: usize) -> rusqlite::Result<Vec<Signal>> {
107 let mut stmt = self.conn.prepare(sql)?;
108 let rows = stmt.query_map(params![limit as i64], |row| row.get::<_, Vec<u8>>(0))?;
109 Self::decode_rows(rows)
110 }
111
112 fn decode_rows(
113 rows: impl Iterator<Item = rusqlite::Result<Vec<u8>>>,
114 ) -> rusqlite::Result<Vec<Signal>> {
115 let mut out = Vec::new();
116 for frame in rows {
117 let frame = frame?;
118 if let Some(sig) = wire::decode(&frame) {
119 out.push(sig);
120 }
121 }
122 Ok(out)
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use signal_schema::{SignalName, Source, Tag, Value, SCHEMA_VERSION};
130
131 fn sig(name: SignalName, value: f64, tag: Option<Tag>) -> Signal {
132 Signal {
133 schema_version: SCHEMA_VERSION,
134 ts: 1_723_100_000_000,
135 source: Source::Git,
136 name,
137 value: Value(value),
138 tag,
139 }
140 }
141
142 #[test]
143 fn round_trip_write_then_query_recent() {
144 let h = History::open_in_memory().unwrap();
145 let repo = Tag::repo_path("/x/repo").unwrap();
146 h.record(&sig(SignalName::CommitsWindow, 5.0, Some(repo.clone())))
147 .unwrap();
148 h.record(&sig(SignalName::CommitsToday, 2.0, Some(repo.clone())))
149 .unwrap();
150 h.record(&sig(SignalName::KeysPerMin, 88.0, None)).unwrap();
151
152 let recent = h.recent(10).unwrap();
153 assert_eq!(recent.len(), 3);
154 // Newest first, and the aggregates survive the round trip exactly.
155 assert_eq!(recent[0].name, SignalName::KeysPerMin);
156 assert_eq!(recent[0].value, Value(88.0));
157 assert_eq!(recent[2].name, SignalName::CommitsWindow);
158 assert_eq!(recent[2].value, Value(5.0));
159 assert_eq!(recent[2].tag.as_ref().unwrap().as_str(), "/x/repo");
160 }
161
162 #[test]
163 fn recent_named_filters_by_metric() {
164 let h = History::open_in_memory().unwrap();
165 for v in [1.0, 2.0, 3.0] {
166 h.record(&sig(SignalName::KeysPerMin, v, None)).unwrap();
167 }
168 h.record(&sig(SignalName::SessionSeconds, 42.0, None))
169 .unwrap();
170
171 let keys = h.recent_named(SignalName::KeysPerMin, 10).unwrap();
172 assert_eq!(keys.len(), 3);
173 assert!(keys.iter().all(|s| s.name == SignalName::KeysPerMin));
174 assert_eq!(keys[0].value, Value(3.0)); // newest first
175 }
176}
crates/signald/src/hub.rs added +149
@@ -0,0 +1,149 @@
1//! # The signal hub — last-value cache + live fan-out (spec §1.3)
2//!
3//! The in-memory heart of the publish side. Collectors [`Hub::publish`] signals;
4//! subscribers [`Hub::subscribe`] and receive the current value of every cached
5//! metric immediately, then a live stream of updates.
6//!
7//! Subscription semantics (spec §1.3): *last-value-cache + live stream.* On
8//! subscribe, the caller is handed the latest value of each `name` (keyed by
9//! name + audited tag), so a renderer paints correct state at once; thereafter
10//! it receives updates as they are published. Channels are unbounded, so a slow
11//! subscriber never blocks a publisher.
12//!
13//! When a `Hub` is built [`Hub::with_history`], every published signal is also
14//! persisted to the SQLite history store, so live and persisted paths share one
15//! write point.
16
17use std::collections::BTreeMap;
18use std::sync::mpsc::{channel, Receiver, Sender};
19use std::sync::{Arc, Mutex};
20
21use signal_schema::Signal;
22
23use crate::history::History;
24
25/// Cache key: metric name discriminant + optional audited tag. Distinct
26/// per-repo / per-core signals coexist; a new value for the same key replaces
27/// the old one (keep-latest).
28type Key = (u8, Option<String>);
29
30/// A cloneable handle to the shared hub. Clones share one cache + subscriber
31/// set behind a mutex.
32#[derive(Clone)]
33pub struct Hub {
34 inner: Arc<Mutex<Inner>>,
35}
36
37struct Inner {
38 cache: BTreeMap<Key, Signal>,
39 subs: Vec<Sender<Signal>>,
40 history: Option<History>,
41}
42
43impl Hub {
44 /// A hub with no history (live-only). Useful for tests and for a renderer
45 /// that never persists.
46 pub fn new() -> Hub {
47 Hub::build(None)
48 }
49
50 /// A hub that also persists every published signal to `history`.
51 pub fn with_history(history: History) -> Hub {
52 Hub::build(Some(history))
53 }
54
55 fn build(history: Option<History>) -> Hub {
56 Hub {
57 inner: Arc::new(Mutex::new(Inner {
58 cache: BTreeMap::new(),
59 subs: Vec::new(),
60 history,
61 })),
62 }
63 }
64
65 /// Publish one signal: persist it (if a history is attached), update the
66 /// last-value cache, and fan it out to every live subscriber. Dead
67 /// subscribers (whose receiver was dropped) are pruned here.
68 pub fn publish(&self, s: Signal) {
69 let mut guard = self.inner.lock().unwrap();
70 let Inner {
71 cache,
72 subs,
73 history,
74 } = &mut *guard;
75
76 if let Some(h) = history {
77 // A history write failing must not stop the live stream.
78 let _ = h.record(&s);
79 }
80 let key = (s.name.to_u8(), s.tag.as_ref().map(|t| t.as_str().to_string()));
81 cache.insert(key, s.clone());
82 subs.retain(|tx| tx.send(s.clone()).is_ok());
83 }
84
85 /// Subscribe: return the current last-value snapshot plus a receiver that
86 /// yields every signal published after this call.
87 pub fn subscribe(&self) -> (Vec<Signal>, Receiver<Signal>) {
88 let mut guard = self.inner.lock().unwrap();
89 let snapshot = guard.cache.values().cloned().collect();
90 let (tx, rx) = channel();
91 guard.subs.push(tx);
92 (snapshot, rx)
93 }
94
95 /// The current last-value snapshot without subscribing.
96 pub fn snapshot(&self) -> Vec<Signal> {
97 self.inner.lock().unwrap().cache.values().cloned().collect()
98 }
99}
100
101impl Default for Hub {
102 fn default() -> Self {
103 Hub::new()
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use signal_schema::{SignalName, Source, Value, SCHEMA_VERSION};
111
112 fn sig(name: SignalName, value: f64) -> Signal {
113 Signal {
114 schema_version: SCHEMA_VERSION,
115 ts: 1,
116 source: Source::Terminal,
117 name,
118 value: Value(value),
119 tag: None,
120 }
121 }
122
123 #[test]
124 fn subscriber_gets_cached_value_then_live_update() {
125 let hub = Hub::new();
126 // A value published before anyone subscribes must still be seen (cache).
127 hub.publish(sig(SignalName::KeysPerMin, 10.0));
128
129 let (snapshot, rx) = hub.subscribe();
130 assert_eq!(snapshot.len(), 1);
131 assert_eq!(snapshot[0].value, Value(10.0));
132
133 // A change after subscribing arrives live.
134 hub.publish(sig(SignalName::KeysPerMin, 25.0));
135 let update = rx.recv().expect("live update");
136 assert_eq!(update.name, SignalName::KeysPerMin);
137 assert_eq!(update.value, Value(25.0));
138 }
139
140 #[test]
141 fn cache_keeps_latest_per_name() {
142 let hub = Hub::new();
143 hub.publish(sig(SignalName::KeysPerMin, 1.0));
144 hub.publish(sig(SignalName::KeysPerMin, 2.0));
145 let snap = hub.snapshot();
146 assert_eq!(snap.len(), 1, "same name collapses to keep-latest");
147 assert_eq!(snap[0].value, Value(2.0));
148 }
149}
crates/signald/src/lib.rs added +296
@@ -0,0 +1,296 @@
1//! # signald (library)
2//!
3//! The collector + transport internals of the ambient-companions daemon, split
4//! into a library so the collectors are unit-testable and the thin `signald`
5//! binary (`src/main.rs`) just wires arguments to the runtime.
6//!
7//! v0.2 implements the **git path** and the **terminal path** end to end, adds a
8//! **SQLite (WAL) history store** ([`history`]), and turns the socket into a
9//! **live pub/sub bus with a last-value cache** ([`hub`], [`publish`]). The
10//! sensitive terminal collector is aggregate-only and ships behind the now-active
11//! differential secret-typing test (see `signal-schema/tests/privacy_invariant.rs`).
12//! The system/hardware collector remains a stub (spec phase 3).
13
14use std::time::{SystemTime, UNIX_EPOCH};
15
16pub mod history;
17pub mod hub;
18
19/// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4).
20pub mod collectors {
21 /// Git collector: shell out to `git` for counts and ages. Counts and
22 /// branch/age scalars only — never diff content. This is the cleanest
23 /// signal in the suite and the first collector wired (spec §2.2, §3).
24 pub mod git {
25 use std::path::Path;
26 use std::process::Command;
27
28 use signal_schema::{Signal, SignalName, Source, Tag, Value, SCHEMA_VERSION};
29
30 use super::super::now_millis;
31
32 /// Rolling window (days) for [`SignalName::CommitsWindow`].
33 pub const WINDOW_DAYS: u64 = 7;
34
35 /// Derive the aggregate git signals for one repo. Every value is a
36 /// scalar `f64`; the only string on the wire is the repo path carried
37 /// in the audited `tag` (spec §1.2). Returns an empty vec if `repo` is
38 /// not a git repo.
39 pub fn collect(repo: &Path) -> Vec<Signal> {
40 if !is_git_repo(repo) {
41 return Vec::new();
42 }
43 let ts = now_millis();
44 let now_secs = ts / 1000;
45 let tag = Tag::repo_path(&repo.to_string_lossy());
46
47 let mk = |name: SignalName, value: f64| Signal {
48 schema_version: SCHEMA_VERSION,
49 ts,
50 source: Source::Git,
51 name,
52 value: Value(value),
53 tag: tag.clone(),
54 };
55
56 vec![
57 mk(
58 SignalName::CommitsWindow,
59 commits_since(repo, &format!("{WINDOW_DAYS} days ago")),
60 ),
61 mk(SignalName::CommitsToday, commits_since(repo, "midnight")),
62 mk(SignalName::BranchCount, branch_count(repo)),
63 mk(
64 SignalName::DaysSinceLastCommit,
65 days_since_last_commit(repo, now_secs),
66 ),
67 ]
68 }
69
70 fn is_git_repo(repo: &Path) -> bool {
71 run_git(repo, &["rev-parse", "--is-inside-work-tree"])
72 .map(|s| s == "true")
73 .unwrap_or(false)
74 }
75
76 /// Count commits reachable from `HEAD` newer than `since` (a git
77 /// approxidate, e.g. "midnight" or "7 days ago").
78 fn commits_since(repo: &Path, since: &str) -> f64 {
79 run_git(
80 repo,
81 &["rev-list", "--count", &format!("--since={since}"), "HEAD"],
82 )
83 .and_then(|s| s.parse::<f64>().ok())
84 .unwrap_or(0.0)
85 }
86
87 fn branch_count(repo: &Path) -> f64 {
88 run_git(repo, &["for-each-ref", "--format=%(refname)", "refs/heads/"])
89 .map(|s| if s.is_empty() { 0 } else { s.lines().count() })
90 .unwrap_or(0) as f64
91 }
92
93 /// Whole days between the most recent `HEAD` commit and now. `0.0` when
94 /// the repo has no commits yet.
95 fn days_since_last_commit(repo: &Path, now_secs: u64) -> f64 {
96 match run_git(repo, &["log", "-1", "--format=%ct", "HEAD"])
97 .and_then(|s| s.parse::<u64>().ok())
98 {
99 Some(last) => (now_secs.saturating_sub(last) / 86_400) as f64,
100 None => 0.0,
101 }
102 }
103
104 /// Run `git -C <repo> <args>` and return trimmed stdout on success.
105 fn run_git(repo: &Path, args: &[&str]) -> Option<String> {
106 let out = Command::new("git")
107 .arg("-C")
108 .arg(repo)
109 .args(args)
110 .output()
111 .ok()?;
112 if !out.status.success() {
113 return None;
114 }
115 Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
116 }
117 }
118
119 /// Terminal collector (the sensitive one): **no input tap, no PTY sniff.**
120 ///
121 /// The shell side (`shell-hooks/signald-hooks.zsh`) counts keystrokes with a
122 /// `zle` widget that increments a *number* and discards the key, and appends
123 /// aggregate count records — `<epoch_ms> <keys> <session_seconds>`, numbers
124 /// only — to a spool file on each `precmd`. This collector reads that spool
125 /// and derives [`SignalName::KeysPerMin`] and [`SignalName::SessionSeconds`].
126 /// It parses numbers; a line that is not three integers is dropped, so the
127 /// spool can carry nothing but counts (the privacy contract, enforced by the
128 /// differential secret-typing test).
129 pub mod terminal {
130 use std::path::Path;
131
132 use signal_schema::{Signal, SignalName, Source, Value, SCHEMA_VERSION};
133
134 use super::super::now_millis;
135
136 /// One aggregate flush parsed from the spool: a timestamp and counts.
137 /// Every field is a number — there is nowhere to put content.
138 #[derive(Debug, Clone, Copy, PartialEq)]
139 pub struct Flush {
140 pub ts_ms: u64,
141 pub keys: u64,
142 pub session_s: u64,
143 }
144
145 /// Parse one spool line. Returns `None` for anything that is not exactly
146 /// three integers, so non-count lines can never survive into a signal.
147 pub fn parse_flush(line: &str) -> Option<Flush> {
148 let mut it = line.split_whitespace();
149 let ts_ms = it.next()?.parse().ok()?;
150 let keys = it.next()?.parse().ok()?;
151 let session_s = it.next()?.parse().ok()?;
152 if it.next().is_some() {
153 return None; // extra fields => not a well-formed count record
154 }
155 Some(Flush {
156 ts_ms,
157 keys,
158 session_s,
159 })
160 }
161
162 /// Read the spool and derive the current aggregate terminal signals.
163 /// Returns an empty vec if the spool is missing or holds no count lines.
164 pub fn collect(spool: &Path) -> Vec<Signal> {
165 let text = match std::fs::read_to_string(spool) {
166 Ok(t) => t,
167 Err(_) => return Vec::new(),
168 };
169 let flushes: Vec<Flush> = text.lines().filter_map(parse_flush).collect();
170 signals_from_flushes(&flushes)
171 }
172
173 /// Pure mapping from parsed flushes to schema signals (unit-testable).
174 pub fn signals_from_flushes(flushes: &[Flush]) -> Vec<Signal> {
175 let Some(last) = flushes.last() else {
176 return Vec::new();
177 };
178 let ts = now_millis();
179 let mk = |name: SignalName, value: f64| Signal {
180 schema_version: SCHEMA_VERSION,
181 ts,
182 source: Source::Terminal,
183 name,
184 value: Value(value),
185 tag: None, // terminal aggregates are never tagged (spec §1.2)
186 };
187 vec![
188 mk(SignalName::KeysPerMin, keys_per_min(flushes)),
189 mk(SignalName::SessionSeconds, last.session_s as f64),
190 ]
191 }
192
193 /// Keys/min from the most recent flush interval; falls back to the raw
194 /// per-flush count when only one flush (or a zero interval) is available.
195 fn keys_per_min(f: &[Flush]) -> f64 {
196 if f.len() >= 2 {
197 let a = &f[f.len() - 2];
198 let b = &f[f.len() - 1];
199 let dt_ms = b.ts_ms.saturating_sub(a.ts_ms);
200 if dt_ms > 0 {
201 return b.keys as f64 * 60_000.0 / dt_ms as f64;
202 }
203 }
204 f.last().map(|x| x.keys as f64).unwrap_or(0.0)
205 }
206 }
207
208 use signal_schema::Signal;
209
210 /// System + hardware collector: **IOKit-only, no powermetrics, no root**
211 /// (spec §1.4). Native macOS.
212 ///
213 /// v0.3: the real collector ships out-of-process as the sibling Swift package
214 /// `macos-collector/` (SwiftPM, not in this cargo workspace), which reads
215 /// aggregate hardware scalars via IOKit and emits `signal-schema` wire frames
216 /// this daemon can parse (see `signal-schema/tests/hardware_wire.rs`). This
217 /// in-process hook stays a stub: signald's live frame-ingest handshake for
218 /// those frames is the next increment.
219 pub fn system_hw_tick() -> Vec<Signal> {
220 todo!("v0.3: hardware signals come from the sibling macos-collector Swift package")
221 }
222}
223
224/// The publish side (spec §1.3): a live pub/sub fan-out with a last-value cache.
225///
226/// On connect a subscriber is handed the current value of every cached `name`
227/// immediately (so a renderer paints correct state at once), then streams
228/// updates as they change. Each subscriber runs on its own thread with its own
229/// unbounded channel, so a slow subscriber never blocks the daemon (spec §1.3:
230/// "a stuck wallpaper process must not stall the audio thread's feed").
231pub mod publish {
232 use std::io;
233 use std::io::Write;
234 use std::os::unix::net::{UnixListener, UnixStream};
235 use std::path::Path;
236 use std::thread;
237
238 use signal_schema::wire;
239
240 use crate::hub::Hub;
241
242 /// Bind `socket_path` and serve the live stream from `hub` to each
243 /// subscriber. Blocks, accepting connections until the listener errors.
244 pub fn serve(socket_path: &Path, hub: Hub) -> io::Result<()> {
245 // Clear any stale socket file from a previous run.
246 let _ = std::fs::remove_file(socket_path);
247 if let Some(parent) = socket_path.parent() {
248 std::fs::create_dir_all(parent)?;
249 }
250 let listener = UnixListener::bind(socket_path)?;
251 eprintln!("signald: listening on {}", socket_path.display());
252
253 for stream in listener.incoming() {
254 match stream {
255 Ok(stream) => {
256 let hub = hub.clone();
257 // One subscriber per thread; a misbehaving one cannot take
258 // the daemon or the other subscribers down.
259 thread::spawn(move || {
260 if let Err(e) = serve_subscriber(stream, hub) {
261 eprintln!("signald: subscriber dropped: {e}");
262 }
263 });
264 }
265 Err(e) => eprintln!("signald: accept error: {e}"),
266 }
267 }
268 Ok(())
269 }
270
271 /// Replay the last-value cache to a freshly connected subscriber, then
272 /// stream live updates until it disconnects.
273 fn serve_subscriber(mut stream: UnixStream, hub: Hub) -> io::Result<()> {
274 let (snapshot, rx) = hub.subscribe();
275 for sig in snapshot {
276 wire::write_frame(&mut stream, &sig)?;
277 }
278 stream.flush()?;
279 // `rx` yields every signal published after we subscribed. A write error
280 // means the subscriber went away; returning drops `rx`, and the hub
281 // prunes the dead sender on its next publish.
282 for sig in rx.iter() {
283 wire::write_frame(&mut stream, &sig)?;
284 stream.flush()?;
285 }
286 Ok(())
287 }
288}
289
290/// Unix millis, best-effort (spec: monotonic-corrected later).
291pub fn now_millis() -> u64 {
292 SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .map(|d| d.as_millis() as u64)
295 .unwrap_or(0)
296}
crates/signald/src/main.rs added +147
@@ -0,0 +1,147 @@
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 (spec §1.3).
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 ┘ (stub, phase 3)
11//! ├─▶ publish: Unix socket (live)
12//! └─▶ SQLite WAL history
13//! ```
14//!
15//! v0.2: the git and terminal collectors are live; the hub caches the latest
16//! value of every metric and streams updates to subscribers; every signal is
17//! persisted to a SQLite (WAL) history store. Collector internals live in the
18//! `signald` library crate; this binary is argument wiring, the producer loop,
19//! and self-attestation.
20//!
21//! Usage:
22//! ```text
23//! signald [--socket <path>] [--db <path>] [--spool <path>]
24//! [--interval-ms <n>] [<repo-path> ...]
25//! ```
26//! With no repo paths, the current directory is watched. The socket defaults to
27//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
28//! history db and terminal spool default alongside it.
29
30use std::path::PathBuf;
31use std::thread;
32use std::time::Duration;
33
34use signal_schema::Source;
35use signald::collectors;
36use signald::history::History;
37use signald::hub::Hub;
38use signald::publish;
39
40struct Config {
41 socket: PathBuf,
42 db: PathBuf,
43 spool: PathBuf,
44 interval: Duration,
45 repos: Vec<PathBuf>,
46}
47
48fn main() {
49 print_self_attestation();
50 let cfg = parse_args();
51
52 let history = match History::open(&cfg.db) {
53 Ok(h) => {
54 eprintln!("signald: history at {}", cfg.db.display());
55 h
56 }
57 Err(e) => {
58 eprintln!("signald: fatal: cannot open history db {}: {e}", cfg.db.display());
59 std::process::exit(1);
60 }
61 };
62 let hub = Hub::with_history(history);
63
64 // Producer: poll the collectors on a tick and publish into the hub. Runs
65 // for the life of the daemon, independent of any subscriber.
66 let producer = hub.clone();
67 let repos = cfg.repos.clone();
68 let spool = cfg.spool.clone();
69 let interval = cfg.interval;
70 thread::spawn(move || loop {
71 for repo in &repos {
72 for sig in collectors::git::collect(repo) {
73 producer.publish(sig);
74 }
75 }
76 for sig in collectors::terminal::collect(&spool) {
77 producer.publish(sig);
78 }
79 thread::sleep(interval);
80 });
81
82 eprintln!("signald: watching {} repo(s), spool {}", cfg.repos.len(), cfg.spool.display());
83 if let Err(e) = publish::serve(&cfg.socket, hub) {
84 eprintln!("signald: fatal: {e}");
85 std::process::exit(1);
86 }
87}
88
89fn parse_args() -> Config {
90 let mut socket: Option<PathBuf> = None;
91 let mut db: Option<PathBuf> = None;
92 let mut spool: Option<PathBuf> = None;
93 let mut interval_ms: u64 = 2000;
94 let mut repos: Vec<PathBuf> = Vec::new();
95
96 let mut args = std::env::args().skip(1);
97 while let Some(arg) = args.next() {
98 match arg.as_str() {
99 "--socket" => socket = args.next().map(PathBuf::from),
100 "--db" => db = args.next().map(PathBuf::from),
101 "--spool" => spool = args.next().map(PathBuf::from),
102 "--interval-ms" => {
103 interval_ms = args.next().and_then(|s| s.parse().ok()).unwrap_or(interval_ms)
104 }
105 _ => repos.push(PathBuf::from(arg)),
106 }
107 }
108
109 if repos.is_empty() {
110 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
111 }
112 let socket = socket.unwrap_or_else(default_socket_path);
113 let base = socket.parent().map(PathBuf::from).unwrap_or_default();
114 Config {
115 db: db.unwrap_or_else(|| base.join("signald.sqlite")),
116 spool: spool.unwrap_or_else(|| base.join("terminal.spool")),
117 interval: Duration::from_millis(interval_ms),
118 repos,
119 socket,
120 }
121}
122
123/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
124fn default_socket_path() -> PathBuf {
125 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
126 return PathBuf::from(dir).join("signald.sock");
127 }
128 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
129 PathBuf::from(home).join(".local/state/signald/sock")
130}
131
132/// Log enabled collectors and assert none holds an input-tap capability. A real
133/// keylogger would need one of the forbidden APIs; their absence is the point,
134/// and this is the runtime half of that guarantee (spec §1.5).
135fn print_self_attestation() {
136 eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION"));
137 eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub");
138 eprintln!(" history: sqlite (WAL), aggregate scalars only");
139 for source in [Source::Terminal, Source::Git, Source::Macos, Source::Hardware] {
140 let state = match source {
141 Source::Git => "ENABLED (aggregate scalars only)",
142 Source::Terminal => "ENABLED (aggregate counts from zsh spool; no input tap)",
143 Source::Macos | Source::Hardware => "stub (phase 3)",
144 };
145 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
146 }
147}
crates/signald/tests/differential_secret_typing.rs added +240
@@ -0,0 +1,240 @@
1//! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5)
2//!
3//! This is the acceptance test for the whole terminal collector, and the single
4//! most important test in the project. It drives the **real production hook**
5//! (`shell-hooks/signald-hooks.zsh`) through a **real interactive zsh under a
6//! real pseudo-terminal** (using zsh's own `zsh/zpty` module, so there is no
7//! Python/libc dependency), *typing a known secret string* the way a person
8//! would type it at a prompt.
9//!
10//! It then runs the exact daemon write path — the terminal [`collect`]or, the
11//! [`Hub`] (last-value cache + fan-out), and the SQLite [`History`] store — and
12//! asserts the secret **never appears**, in any of {plain, reversed, hex,
13//! base64}, in:
14//! * the shell spool the hook writes,
15//! * the wire encoding of every emitted signal,
16//! * the SQLite history files and the `recent()` query result,
17//! * the in-memory hub snapshot.
18//!
19//! Why this proves no content leaks: real `zle` keystroke counting increments a
20//! *number* per key and discards the key; the hook flushes only that number.
21//! So the only thing the whole pipeline ever receives about the typing is a
22//! count — there is no channel through which the characters could travel. The
23//! test types the secret for real and checks every downstream artifact to prove
24//! that empirically, not just by inspection.
25
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use signal_schema::{wire, SignalName};
30use signald::collectors::terminal;
31use signald::history::History;
32use signald::hub::Hub;
33
34/// A distinctive planted secret — long and non-dictionary so an incidental
35/// byte-collision in a binary artifact is not credible.
36const SECRET: &str = "hunter2-CorrectHorseBatteryStaple-9f3a-SUPERSECRET";
37
38#[test]
39fn secret_typed_at_prompt_never_reaches_any_output() {
40 let harness = TempDir::new("ambient-differential");
41 let spool = harness.path().join("terminal.spool");
42 let db = harness.path().join("signald.sqlite");
43
44 // 1. Type the secret through the real hook + real zle under a real pty.
45 let spool_bytes = drive_real_hook(&harness, &spool)
46 .expect("zsh/zpty harness must run (this is the privacy ship-gate)");
47
48 // The path must genuinely have processed the typing: at least one flush
49 // must have counted as many keys as the secret is long (it was typed after
50 // `echo `). Otherwise we'd be "proving" absence over an empty run.
51 let flushes: Vec<terminal::Flush> = String::from_utf8_lossy(&spool_bytes)
52 .lines()
53 .filter_map(terminal::parse_flush)
54 .collect();
55 let max_keys = flushes.iter().map(|f| f.keys).max().unwrap_or(0);
56 assert!(
57 max_keys >= SECRET.chars().count() as u64,
58 "the secret does not appear to have been typed through the collector \
59 (max keys counted = {max_keys}, secret length = {})",
60 SECRET.chars().count()
61 );
62
63 // 2. Run the exact daemon write path: collector -> hub(+history).
64 let signals = terminal::collect(&spool);
65 assert!(!signals.is_empty(), "terminal collector produced no signals");
66 assert!(signals.iter().any(|s| s.name == SignalName::KeysPerMin));
67 assert!(signals.iter().any(|s| s.name == SignalName::SessionSeconds));
68
69 let hub = Hub::with_history(History::open(&db).unwrap());
70 let mut wire_bytes = Vec::new();
71 for s in &signals {
72 wire_bytes.extend_from_slice(&wire::encode(s));
73 hub.publish(s.clone());
74 }
75
76 // 3. Read history back through the real query a renderer would use.
77 let read_back = History::open(&db).unwrap().recent(1000).unwrap();
78 assert!(!read_back.is_empty(), "history query returned nothing");
79
80 // 4. Gather every artifact and scan them all for the secret, in any encoding.
81 let mut artifacts: Vec<(&str, Vec<u8>)> = Vec::new();
82 artifacts.push(("shell spool", spool_bytes.clone()));
83 artifacts.push(("wire encoding", wire_bytes));
84 artifacts.push(("collected signals (debug)", format!("{signals:?}").into_bytes()));
85 artifacts.push(("hub snapshot (debug)", format!("{:?}", hub.snapshot()).into_bytes()));
86 artifacts.push(("history recent() (debug)", format!("{read_back:?}").into_bytes()));
87 // Every on-disk SQLite file (main db + -wal + -shm).
88 for f in sqlite_files(&db) {
89 let bytes = std::fs::read(&f).unwrap_or_default();
90 artifacts.push(("sqlite file", bytes));
91 }
92
93 let needles = secret_encodings(SECRET);
94 for (label, bytes) in &artifacts {
95 for (enc, needle) in &needles {
96 assert!(
97 !contains(bytes, needle),
98 "SECRET LEAK: found the secret ({enc}) in `{label}` — the terminal \
99 collector must emit aggregate counts only"
100 );
101 }
102 }
103}
104
105// --- the zsh/zpty typing harness ---
106
107/// Write a zpty driver, run it under `zsh`, and return the spool it produced.
108/// The driver spawns an interactive zsh under a pty, sources the real hook, and
109/// types the secret as `echo` arguments — real keystrokes through real `zle`.
110fn drive_real_hook(harness: &TempDir, spool: &Path) -> Option<Vec<u8>> {
111 let hooks = repo_root().join("shell-hooks/signald-hooks.zsh");
112 assert!(hooks.exists(), "production hook missing at {}", hooks.display());
113 let driver = harness.path().join("driver.zsh");
114 std::fs::write(&driver, DRIVER).ok()?;
115 // Ensure a clean spool.
116 let _ = std::fs::write(spool, b"");
117
118 let status = Command::new("zsh")
119 .arg("-f")
120 .arg(&driver)
121 .env("SIGNALD_SPOOL", spool)
122 .env("HOOKS", &hooks)
123 .env("SECRET", SECRET)
124 .status()
125 .ok()?;
126 if !status.success() {
127 return None;
128 }
129 std::fs::read(spool).ok().filter(|b| !b.is_empty())
130}
131
132/// The pty driver. `zsh/zpty` gives us a real pseudo-terminal from within zsh —
133/// no external dependency. We drain output between writes so the inner shell
134/// makes progress, and type the secret as ordinary command-line input.
135const DRIVER: &str = r#"
136zmodload zsh/zpty || exit 3
137zpty SH zsh -f -i || exit 4
138drain() { local x; while zpty -r -t SH x 2>/dev/null; do :; done }
139sleep 0.4; drain
140zpty -w SH "source $HOOKS"
141sleep 0.3; drain
142zpty -w SH "echo $SECRET"
143sleep 0.5; drain
144zpty -w SH "echo typed $SECRET twice $SECRET"
145sleep 0.5; drain
146zpty -w SH "exit"
147sleep 0.3
148zpty -d SH 2>/dev/null
149"#;
150
151// --- encodings + scanning ---
152
153/// The secret in every representation the spec calls out (plain, reversed, hex,
154/// base64), as raw byte needles to search for.
155fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec<u8>)> {
156 let b = secret.as_bytes();
157 let reversed: Vec<u8> = b.iter().rev().copied().collect();
158 vec![
159 ("plain", b.to_vec()),
160 ("reversed", reversed),
161 ("hex", hex(b).into_bytes()),
162 ("base64", base64(b).into_bytes()),
163 ]
164}
165
166fn contains(hay: &[u8], needle: &[u8]) -> bool {
167 if needle.is_empty() || hay.len() < needle.len() {
168 return false;
169 }
170 hay.windows(needle.len()).any(|w| w == needle)
171}
172
173fn hex(bytes: &[u8]) -> String {
174 let mut s = String::with_capacity(bytes.len() * 2);
175 for b in bytes {
176 s.push_str(&format!("{b:02x}"));
177 }
178 s
179}
180
181fn base64(bytes: &[u8]) -> String {
182 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
183 let mut out = String::new();
184 for chunk in bytes.chunks(3) {
185 let b0 = chunk[0] as u32;
186 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
187 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
188 let n = (b0 << 16) | (b1 << 8) | b2;
189 out.push(T[(n >> 18 & 63) as usize] as char);
190 out.push(T[(n >> 12 & 63) as usize] as char);
191 out.push(if chunk.len() > 1 { T[(n >> 6 & 63) as usize] as char } else { '=' });
192 out.push(if chunk.len() > 2 { T[(n & 63) as usize] as char } else { '=' });
193 }
194 out
195}
196
197// --- helpers ---
198
199fn sqlite_files(db: &Path) -> Vec<PathBuf> {
200 let mut v = vec![db.to_path_buf()];
201 for suffix in ["-wal", "-shm"] {
202 let mut p = db.as_os_str().to_os_string();
203 p.push(suffix);
204 v.push(PathBuf::from(p));
205 }
206 v.into_iter().filter(|p| p.exists()).collect()
207}
208
209fn repo_root() -> PathBuf {
210 // CARGO_MANIFEST_DIR = <root>/crates/signald
211 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
212 .join("../..")
213 .canonicalize()
214 .expect("canonicalize repo root")
215}
216
217struct TempDir {
218 dir: PathBuf,
219}
220
221impl TempDir {
222 fn new(prefix: &str) -> TempDir {
223 let nanos = std::time::SystemTime::now()
224 .duration_since(std::time::UNIX_EPOCH)
225 .unwrap()
226 .as_nanos();
227 let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()));
228 std::fs::create_dir_all(&dir).unwrap();
229 TempDir { dir }
230 }
231 fn path(&self) -> &Path {
232 &self.dir
233 }
234}
235
236impl Drop for TempDir {
237 fn drop(&mut self) {
238 let _ = std::fs::remove_dir_all(&self.dir);
239 }
240}
crates/signald/tests/git_collector.rs added +126
@@ -0,0 +1,126 @@
1//! Git collector acceptance test (spec §5 Phase 1).
2//!
3//! Builds a real temp git repo, drives the collector against it, and asserts the
4//! aggregate scalars — commits-in-window, commits-today, branch count, and
5//! days-since-last-commit — match the repo's true state. Also covers the stale
6//! repo (an old commit) that the garden renders as wilt.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use signal_schema::{Signal, SignalName};
12use signald::collectors::git;
13
14#[test]
15fn fresh_repo_reports_today_commits_and_no_staleness() {
16 let repo = TempRepo::init();
17 repo.commit_now("first");
18 repo.commit_now("second");
19 repo.commit_now("third");
20
21 let signals = git::collect(repo.path());
22
23 assert_eq!(value(&signals, SignalName::CommitsToday), 3.0);
24 assert_eq!(value(&signals, SignalName::CommitsWindow), 3.0);
25 assert_eq!(value(&signals, SignalName::BranchCount), 1.0);
26 assert_eq!(value(&signals, SignalName::DaysSinceLastCommit), 0.0);
27}
28
29#[test]
30fn stale_repo_reports_zero_recent_commits_and_high_staleness() {
31 let repo = TempRepo::init();
32 repo.commit_days_ago("ancient", 40);
33
34 let signals = git::collect(repo.path());
35
36 assert_eq!(value(&signals, SignalName::CommitsToday), 0.0);
37 assert_eq!(value(&signals, SignalName::CommitsWindow), 0.0);
38 let stale = value(&signals, SignalName::DaysSinceLastCommit);
39 assert!(stale >= 39.0, "expected ~40 days stale, got {stale}");
40}
41
42#[test]
43fn non_git_directory_yields_no_signals() {
44 let dir = unique_dir("ambient-nongit");
45 std::fs::create_dir_all(&dir).unwrap();
46 let signals = git::collect(&dir);
47 assert!(signals.is_empty());
48 let _ = std::fs::remove_dir_all(&dir);
49}
50
51// --- helpers ---
52
53fn value(signals: &[Signal], name: SignalName) -> f64 {
54 signals
55 .iter()
56 .find(|s| s.name == name)
57 .unwrap_or_else(|| panic!("signal {name:?} missing"))
58 .value
59 .0
60}
61
62struct TempRepo {
63 dir: PathBuf,
64}
65
66impl TempRepo {
67 fn init() -> TempRepo {
68 let dir = unique_dir("ambient-gitcollector");
69 std::fs::create_dir_all(&dir).unwrap();
70 run(&dir, &["init", "-q"], &[]);
71 // Local identity so commits work without global git config.
72 run(&dir, &["config", "user.email", "test@example.com"], &[]);
73 run(&dir, &["config", "user.name", "Test"], &[]);
74 TempRepo { dir }
75 }
76
77 fn path(&self) -> &Path {
78 &self.dir
79 }
80
81 fn commit_now(&self, msg: &str) {
82 run(&self.dir, &["commit", "--allow-empty", "-q", "-m", msg], &[]);
83 }
84
85 fn commit_days_ago(&self, msg: &str, days: u64) {
86 let secs = std::time::SystemTime::now()
87 .duration_since(std::time::UNIX_EPOCH)
88 .unwrap()
89 .as_secs()
90 - days * 86_400;
91 let date = format!("{secs} +0000");
92 run(
93 &self.dir,
94 &["commit", "--allow-empty", "-q", "-m", msg],
95 &[("GIT_AUTHOR_DATE", &date), ("GIT_COMMITTER_DATE", &date)],
96 );
97 }
98}
99
100impl Drop for TempRepo {
101 fn drop(&mut self) {
102 let _ = std::fs::remove_dir_all(&self.dir);
103 }
104}
105
106fn run(repo: &Path, args: &[&str], envs: &[(&str, &str)]) {
107 let mut cmd = Command::new("git");
108 cmd.arg("-C").arg(repo).args(args);
109 for (k, v) in envs {
110 cmd.env(k, v);
111 }
112 let out = cmd.output().expect("run git");
113 assert!(
114 out.status.success(),
115 "git {args:?} failed: {}",
116 String::from_utf8_lossy(&out.stderr)
117 );
118}
119
120fn unique_dir(prefix: &str) -> PathBuf {
121 let nanos = std::time::SystemTime::now()
122 .duration_since(std::time::UNIX_EPOCH)
123 .unwrap()
124 .as_nanos();
125 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
126}
crates/signald/tests/streaming.rs added +75
@@ -0,0 +1,75 @@
1//! Live-streaming acceptance test (spec §1.3): a subscriber gets the current
2//! value of each metric immediately on connect (last-value cache), then receives
3//! updates as values change — over the real Unix socket, not just the hub.
4
5use std::io::BufReader;
6use std::os::unix::net::UnixStream;
7use std::path::{Path, PathBuf};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use signal_schema::{wire, Signal, SignalName, Source, Value, SCHEMA_VERSION};
12use signald::hub::Hub;
13use signald::publish;
14
15fn sig(name: SignalName, value: f64) -> Signal {
16 Signal {
17 schema_version: SCHEMA_VERSION,
18 ts: 1,
19 source: Source::Terminal,
20 name,
21 value: Value(value),
22 tag: None,
23 }
24}
25
26#[test]
27fn subscriber_sees_cached_value_then_a_live_update() {
28 let socket = unique_socket();
29 let hub = Hub::new();
30
31 // A value published before anyone connects must still reach a subscriber
32 // (the last-value cache).
33 hub.publish(sig(SignalName::KeysPerMin, 12.0));
34
35 let serve_hub = hub.clone();
36 let serve_socket = socket.clone();
37 thread::spawn(move || {
38 let _ = publish::serve(&serve_socket, serve_hub);
39 });
40
41 let stream = connect(&socket);
42 let mut reader = BufReader::new(stream);
43
44 // 1. On connect: the cached snapshot arrives immediately.
45 let first = wire::read_frame(&mut reader).unwrap().expect("snapshot frame");
46 assert_eq!(first.name, SignalName::KeysPerMin);
47 assert_eq!(first.value, Value(12.0));
48
49 // 2. A change after subscribing is streamed live.
50 hub.publish(sig(SignalName::KeysPerMin, 99.0));
51 let update = wire::read_frame(&mut reader).unwrap().expect("live update frame");
52 assert_eq!(update.name, SignalName::KeysPerMin);
53 assert_eq!(update.value, Value(99.0));
54
55 let _ = std::fs::remove_file(&socket);
56}
57
58fn connect(socket: &Path) -> UnixStream {
59 let deadline = Instant::now() + Duration::from_secs(5);
60 loop {
61 if let Ok(s) = UnixStream::connect(socket) {
62 return s;
63 }
64 assert!(Instant::now() < deadline, "signald socket never came up");
65 thread::sleep(Duration::from_millis(20));
66 }
67}
68
69fn unique_socket() -> PathBuf {
70 let nanos = std::time::SystemTime::now()
71 .duration_since(std::time::UNIX_EPOCH)
72 .unwrap()
73 .as_nanos();
74 std::env::temp_dir().join(format!("signald-stream-{}-{nanos}.sock", std::process::id()))
75}
crates/terminal-garden/Cargo.toml added +13
@@ -0,0 +1,13 @@
1[package]
2name = "terminal-garden"
3version.workspace = true
4edition.workspace = true
5license.workspace = true
6description = "First renderer (spec build order §3): a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor."
7
8[[bin]]
9name = "terminal-garden"
10path = "src/main.rs"
11
12[dependencies]
13signal-schema = { path = "../signal-schema" }
crates/terminal-garden/src/lib.rs added +237
@@ -0,0 +1,237 @@
1//! # terminal-garden (library)
2//!
3//! The pure garden model: signals in, a rendered garden out. Kept as a library
4//! so the mapping is unit-testable independently of the socket subscriber in
5//! `src/main.rs`.
6//!
7//! Each watched repo is a plot; growth stage is a function of recent commit
8//! 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
10//! browns as a repo goes stale.
11
12use std::collections::BTreeMap;
13
14use signal_schema::{Signal, SignalName};
15
16/// Growth stage, driven by recent commit volume. `Ord` is meaningful: more
17/// commits never yields a lower stage.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
19pub enum Stage {
20 Seed,
21 Sprout,
22 Sapling,
23 Tree,
24}
25
26impl Stage {
27 /// Stage from commits within the collector window (spec: growth tracks
28 /// cumulative recent commit activity).
29 pub fn from_commits(commits_window: f64) -> Stage {
30 match commits_window as u64 {
31 0 => Stage::Seed,
32 1..=2 => Stage::Sprout,
33 3..=6 => Stage::Sapling,
34 _ => Stage::Tree,
35 }
36 }
37
38 fn glyph(self) -> &'static str {
39 match self {
40 Stage::Seed => ".",
41 Stage::Sprout => "\u{1F331}", // 🌱
42 Stage::Sapling => "\u{1F33F}", // 🌿
43 Stage::Tree => "\u{1F333}", // 🌳
44 }
45 }
46}
47
48/// Health (wilt), driven by staleness. `Ord` is meaningful: more days stale
49/// never yields a healthier state.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
51pub enum Health {
52 Fresh,
53 Dry,
54 Wilting,
55 Withered,
56}
57
58impl Health {
59 /// Health from whole days since the last commit.
60 pub fn from_days_since(days_since: f64) -> Health {
61 match days_since as u64 {
62 0..=1 => Health::Fresh,
63 2..=6 => Health::Dry,
64 7..=13 => Health::Wilting,
65 _ => Health::Withered,
66 }
67 }
68
69 fn label(self) -> &'static str {
70 match self {
71 Health::Fresh => "fresh",
72 Health::Dry => "dry",
73 Health::Wilting => "wilting",
74 Health::Withered => "withered",
75 }
76 }
77}
78
79/// One repo's rendered plot.
80#[derive(Debug, Clone, PartialEq)]
81pub struct Plot {
82 pub repo: String,
83 pub commits_window: f64,
84 pub commits_today: f64,
85 pub branch_count: f64,
86 pub days_since: f64,
87}
88
89impl Plot {
90 pub fn stage(&self) -> Stage {
91 Stage::from_commits(self.commits_window)
92 }
93
94 pub fn health(&self) -> Health {
95 Health::from_days_since(self.days_since)
96 }
97
98 /// The glyph shown for the plant: a withered plot browns regardless of how
99 /// tall it once grew; a wilting one droops; otherwise it shows its stage.
100 pub fn glyph(&self) -> &'static str {
101 match self.health() {
102 Health::Withered => "\u{1F342}", // 🍂
103 Health::Wilting => "\u{1F940}", // 🥀
104 _ => self.stage().glyph(),
105 }
106 }
107
108 fn render_line(&self) -> String {
109 format!(
110 " {glyph} {repo:<24} stage={stage:<7} health={health:<8} \
111 (commits: {win} in window, {today} today | branches: {branches} | last commit {days}d ago)",
112 glyph = self.glyph(),
113 repo = short_name(&self.repo),
114 stage = format!("{:?}", self.stage()).to_lowercase(),
115 health = self.health().label(),
116 win = self.commits_window as u64,
117 today = self.commits_today as u64,
118 branches = self.branch_count as u64,
119 days = self.days_since as u64,
120 )
121 }
122}
123
124/// Fold a snapshot of signals into one plot per repo (keyed by the audited repo
125/// tag; untagged signals collapse into a single unnamed plot). Later git names
126/// (`Commits5m`, diff-line counts, dirty-worktree) are simply ignored here.
127pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> {
128 let mut by_repo: BTreeMap<String, Plot> = BTreeMap::new();
129
130 for s in signals {
131 let key = s.tag.as_ref().map(|t| t.as_str().to_string()).unwrap_or_default();
132 let plot = by_repo.entry(key.clone()).or_insert_with(|| Plot {
133 repo: key.clone(),
134 commits_window: 0.0,
135 commits_today: 0.0,
136 branch_count: 0.0,
137 days_since: 0.0,
138 });
139 match s.name {
140 SignalName::CommitsWindow => plot.commits_window = s.value.0,
141 SignalName::CommitsToday => plot.commits_today = s.value.0,
142 SignalName::BranchCount => plot.branch_count = s.value.0,
143 SignalName::DaysSinceLastCommit => plot.days_since = s.value.0,
144 _ => {}
145 }
146 }
147
148 by_repo.into_values().collect()
149}
150
151/// Render the whole garden to a text block (spec §2.2: a text render is fine for
152/// v0.1).
153pub fn render(plots: &[Plot]) -> String {
154 let mut out = String::new();
155 out.push_str("terminal-garden\n");
156 out.push_str("===============\n");
157 if plots.is_empty() {
158 out.push_str(" (no plots yet — waiting for git signals)\n");
159 return out;
160 }
161 for plot in plots {
162 out.push_str(&plot.render_line());
163 out.push('\n');
164 }
165 out
166}
167
168fn short_name(repo: &str) -> String {
169 if repo.is_empty() {
170 return "(repo)".to_string();
171 }
172 repo.rsplit('/').next().unwrap_or(repo).to_string()
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use signal_schema::{Source, Tag, Value, SCHEMA_VERSION};
179
180 #[test]
181 fn more_commits_grow_a_taller_plant() {
182 assert!(Stage::from_commits(0.0) < Stage::from_commits(2.0));
183 assert!(Stage::from_commits(2.0) < Stage::from_commits(5.0));
184 assert!(Stage::from_commits(5.0) < Stage::from_commits(20.0));
185 assert_eq!(Stage::from_commits(0.0), Stage::Seed);
186 assert_eq!(Stage::from_commits(20.0), Stage::Tree);
187 }
188
189 #[test]
190 fn staleness_makes_a_plant_wilt() {
191 assert_eq!(Health::from_days_since(0.0), Health::Fresh);
192 assert!(Health::from_days_since(0.0) < Health::from_days_since(3.0));
193 assert!(Health::from_days_since(3.0) < Health::from_days_since(10.0));
194 assert!(Health::from_days_since(10.0) < Health::from_days_since(40.0));
195 assert_eq!(Health::from_days_since(40.0), Health::Withered);
196 }
197
198 #[test]
199 fn withered_plot_shows_the_brown_glyph_over_its_stage() {
200 let plot = Plot {
201 repo: "/x/big-repo".to_string(),
202 commits_window: 50.0, // would be a Tree
203 commits_today: 0.0,
204 branch_count: 1.0,
205 days_since: 30.0, // but it's withered
206 };
207 assert_eq!(plot.stage(), Stage::Tree);
208 assert_eq!(plot.health(), Health::Withered);
209 assert_eq!(plot.glyph(), "\u{1F342}");
210 }
211
212 #[test]
213 fn plots_group_by_repo_tag() {
214 let mk = |name, value, repo: &str| Signal {
215 schema_version: SCHEMA_VERSION,
216 ts: 1,
217 source: Source::Git,
218 name,
219 value: Value(value),
220 tag: Some(Tag::repo_path(repo).unwrap()),
221 };
222 let signals = vec![
223 mk(SignalName::CommitsWindow, 5.0, "/a/alpha"),
224 mk(SignalName::DaysSinceLastCommit, 0.0, "/a/alpha"),
225 mk(SignalName::CommitsWindow, 0.0, "/b/beta"),
226 mk(SignalName::DaysSinceLastCommit, 20.0, "/b/beta"),
227 ];
228 let plots = plots_from_signals(&signals);
229 assert_eq!(plots.len(), 2);
230 let alpha = plots.iter().find(|p| p.repo == "/a/alpha").unwrap();
231 let beta = plots.iter().find(|p| p.repo == "/b/beta").unwrap();
232 assert_eq!(alpha.stage(), Stage::Sapling);
233 assert_eq!(alpha.health(), Health::Fresh);
234 assert_eq!(beta.stage(), Stage::Seed);
235 assert_eq!(beta.health(), Health::Withered);
236 }
237}
crates/terminal-garden/src/main.rs added +89
@@ -0,0 +1,89 @@
1//! # terminal-garden
2//!
3//! The first face (spec §3: "the garden is the recommended first build").
4//! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy
5//! questions — so the garden proves the bus before anything reads the shell.
6//!
7//! Like every renderer, the garden is thin: connect to the socket → read the
8//! git aggregates → fold them into per-repo plots → render. It never touches a
9//! sensor. The mapping (commits → growth, staleness → wilt) lives in the
10//! `terminal-garden` library so it can be unit-tested; this binary is just the
11//! subscriber loop.
12//!
13//! Usage:
14//! ```text
15//! terminal-garden [--socket <path>]
16//! ```
17//! Socket defaults to `$XDG_RUNTIME_DIR/signald.sock`, falling back to
18//! `~/.local/state/signald/sock`.
19
20use std::collections::BTreeMap;
21use std::io::BufReader;
22use std::os::unix::net::UnixStream;
23use std::path::PathBuf;
24
25use signal_schema::{wire, Signal, SignalName};
26use terminal_garden::{plots_from_signals, render};
27
28/// The `name`s the garden cares about (spec §2.2 — the git aggregates).
29const SUBSCRIBE: &[SignalName] = &[
30 SignalName::CommitsWindow,
31 SignalName::CommitsToday,
32 SignalName::BranchCount,
33 SignalName::DaysSinceLastCommit,
34];
35
36fn main() {
37 let socket = parse_socket();
38 if let Err(e) = run(&socket) {
39 eprintln!("terminal-garden: could not read signald at {}: {e}", socket.display());
40 eprintln!("terminal-garden: is signald running? (signald --socket {})", socket.display());
41 std::process::exit(1);
42 }
43}
44
45/// Connect and render live: the daemon replays the last-value cache on connect,
46/// then streams updates. We keep the latest value of each metric (keyed by name
47/// + repo tag) and re-render the garden on every frame. `Ok(())` is a clean EOF
48/// (the daemon closed the stream).
49fn run(socket: &PathBuf) -> std::io::Result<()> {
50 let stream = UnixStream::connect(socket)?;
51 let mut reader = BufReader::new(stream);
52
53 // Keyed by (metric name, repo tag) so per-repo signals coexist and updates
54 // replace prior values rather than accumulating.
55 let mut latest: BTreeMap<(u8, Option<String>), Signal> = BTreeMap::new();
56 while let Some(sig) = wire::read_frame(&mut reader)? {
57 if !SUBSCRIBE.contains(&sig.name) {
58 continue;
59 }
60 let key = (sig.name.to_u8(), sig.tag.as_ref().map(|t| t.as_str().to_string()));
61 latest.insert(key, sig);
62
63 let signals: Vec<Signal> = latest.values().cloned().collect();
64 let plots = plots_from_signals(&signals);
65 // Clear + home so the garden redraws in place as signals change.
66 print!("\x1b[2J\x1b[H{}", render(&plots));
67 }
68 Ok(())
69}
70
71fn parse_socket() -> PathBuf {
72 let mut args = std::env::args().skip(1);
73 while let Some(arg) = args.next() {
74 if arg == "--socket" {
75 if let Some(path) = args.next() {
76 return PathBuf::from(path);
77 }
78 }
79 }
80 default_socket_path()
81}
82
83fn default_socket_path() -> PathBuf {
84 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
85 return PathBuf::from(dir).join("signald.sock");
86 }
87 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
88 PathBuf::from(home).join(".local/state/signald/sock")
89}
macos-collector/.gitignore added +1
@@ -0,0 +1 @@
1.build/
macos-collector/Package.swift added +22
@@ -0,0 +1,22 @@
1// swift-tools-version:5.9
2import PackageDescription
3
4// The macOS IOKit hardware collector (spec Phase 3). A *sibling* of the Rust
5// cargo workspace, deliberately kept OUT of it: SwiftPM and cargo do not share a
6// build system. This package speaks the shared wire protocol (signal-schema) and
7// nothing else it is one more producer for the same daemon/schema.
8let package = Package(
9 name: "macos-collector",
10 platforms: [.macOS(.v13)],
11 products: [
12 .executable(name: "macos-collector", targets: ["macos-collector"]),
13 ],
14 targets: [
15 // Wire encoder + IOKit reads, in a library so they are unit-testable.
16 .target(name: "CollectorCore"),
17 // Thin CLI: flags -> collect loop -> emit frames.
18 .executableTarget(name: "macos-collector", dependencies: ["CollectorCore"]),
19 // The cross-language wire-contract test (canonical frame bytes).
20 .testTarget(name: "CollectorCoreTests", dependencies: ["CollectorCore"]),
21 ]
22)
macos-collector/README.md added +108
@@ -0,0 +1,108 @@
1# macos-collector
2
3*The macOS IOKit hardware collector (spec Phase 3) — a sibling of the Rust
4workspace that speaks the shared wire protocol.*
5
6This is a **SwiftPM** package, kept deliberately **out** of the cargo workspace
7(SwiftPM and cargo do not share a build system). It is not a fifth face; it is
8one more **producer** for the same daemon and the same schema. It reads
9aggregate hardware scalars via **IOKit only — no `powermetrics`, no root** — and
10emits them as `signal-schema` wire frames, the exact byte format `signald`
11already parses.
12
13```sh
14swift build # builds the collector
15swift test # the cross-language wire-contract test
16swift run macos-collector --once # one real IOKit read (summary on stderr)
17```
18
19## What it reads (IOKit-only, no root)
20
21| Metric | `SignalName` | Source | How |
22|---|---|---|---|
23| Aggregate CPU load `[0,1]` | `cpu_load` (28) | hardware | Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` tick deltas |
24| Battery percentage `[0,100]` | `battery_pct` (17) | macos | IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
25| Charging (1/0) | `charging` (18) | macos | IOKit power sources |
26| Battery draw (W) | `battery_draw_w` (27) | hardware | IORegistry `AppleSmartBattery` Amperage×Voltage |
27| Thermal state `0..3` | `thermal_state` (19) | macos | `ProcessInfo.thermalState` |
28
29**Intentionally omitted:** GPU utilization and fan RPM. They are reachable in
30principle 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**.
33Battery signals are omitted on a machine with no internal battery (a desktop).
34
35Every value is an **aggregate scalar**. This collector has no code path that can
36read user content: it touches only hardware counters and power registers. The
37`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
39terminal collector — and the repo's `forbidden_symbol_scan` gate scans these
40`.swift` files too.
41
42## Modes
43
44```
45macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>]
46```
47
48- *(default)* stream raw wire frames to stdout every `--interval-ms` (default 1000).
49- `--once` — one tick: a human-readable summary on **stderr**, the frames on **stdout**.
50- `--hex` — emit each frame as a hex line (a bridge decodes; also handy to eyeball).
51- `--out <path>` — append raw frames to a file (a spool the daemon can tail) instead of stdout.
52
53## The Swift ↔ Rust wire contract (the byte format)
54
55Both languages implement the **same** length-prefixed frame. The authoritative
56definition lives in Rust (`crates/signal-schema/src/lib.rs`, module `wire`); the
57Swift encoder (`Sources/CollectorCore/Wire.swift`) mirrors it exactly. All
58multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are
59**little-endian**.
60
61```text
62frame:
63 [u32 body_len] little-endian length of body
64 body:
65 [u16 schema_version] must equal 3 (v0.3); a daemon drops other versions
66 [u64 ts] unix milliseconds
67 [u8 source] 0=terminal 1=git 2=macos 3=hardware
68 [u8 name] SignalName discriminant (see table above)
69 [f64 value] the ONLY payload channel — no content field exists
70 [u8 tag_present] 0 = no tag, 1 = tag follows
71 [u16 tag_len] present only if tag_present == 1
72 [tag_len bytes] UTF-8 audited identifier; hardware signals never tag
73```
74
75### Canonical frame (pinned by tests on both sides)
76
77`schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28), value=0.5,
78tag=none` encodes to these exact 25 bytes:
79
80```text
8115 00 00 00 body_len = 21
8203 00 schema_version = 3
8300 00 00 00 00 00 00 00 ts = 0
8403 source = Hardware
851C name = CpuLoad (28)
8600 00 00 00 00 00 E0 3F value = 0.5 (f64 LE)
8700 tag_present = 0
88```
89
90This identical literal is asserted in **both**:
91- Rust — `crates/signal-schema/tests/hardware_wire.rs` (`wire::decode` parses it,
92 `wire::encode` reproduces it — this is signald's own parse path), and
93- Swift — `Tests/CollectorCoreTests/WireTests.swift` (`encode` produces it).
94
95Because both sides independently commit to the same bytes, cross-*process*
96execution 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.)
99
100## Wiring into signald (Phase 3 scope note)
101
102`signald`'s in-process `system_hw_tick` collector is a stub; this out-of-process
103Swift collector is its real realization. The **live socket handshake** by which
104`signald` ingests these frames is intentionally minimal in v0.3 (spec's stated
105Phase 3 scope): the collector emits frames to stdout / `--out` today, and the
106guarantee that `signald` parses them is the shared wire-contract test above
107(which exercises the identical `wire::decode` the socket path uses). A full
108cross-process ingest handshake is the next increment.
macos-collector/Sources/CollectorCore/Hardware.swift added +166
@@ -0,0 +1,166 @@
1import Foundation
2import IOKit
3import IOKit.ps
4
5// The hardware reads. IOKit-only, no `powermetrics`, no root (spec §1.4, §6):
6// * CPU load Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` deltas
7// * battery % IOKit power sources (`IOPSCopyPowerSourcesInfo`)
8// * charging IOKit power sources
9// * battery draw IORegistry `AppleSmartBattery` (Amperage * Voltage)
10// * thermal `ProcessInfo.thermalState` (aggregate enum, no root)
11//
12// 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.
16//
17// Every value produced is an aggregate scalar. There is no code path here that
18// reads user content this file cannot, because it only ever touches hardware
19// counters and power registers.
20
21/// Cumulative CPU ticks summed across all cores.
22private struct CPUTicks {
23 var used: UInt64
24 var total: UInt64
25}
26
27/// Read cumulative CPU ticks via Mach `host_processor_info`. Non-privileged.
28private func readCPUTicks() -> CPUTicks? {
29 var info: processor_info_array_t?
30 var infoCount: mach_msg_type_number_t = 0
31 var cpuCount: natural_t = 0
32
33 let result = host_processor_info(
34 mach_host_self(),
35 PROCESSOR_CPU_LOAD_INFO,
36 &cpuCount,
37 &info,
38 &infoCount
39 )
40 guard result == KERN_SUCCESS, let info else { return nil }
41 defer {
42 let addr = vm_address_t(UInt(bitPattern: UnsafeRawPointer(info)))
43 vm_deallocate(mach_task_self_, addr, vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
44 }
45
46 let states = Int(CPU_STATE_MAX)
47 var used: UInt64 = 0
48 var total: UInt64 = 0
49 for cpu in 0..<Int(cpuCount) {
50 let base = cpu * states
51 let user = UInt64(info[base + Int(CPU_STATE_USER)])
52 let system = UInt64(info[base + Int(CPU_STATE_SYSTEM)])
53 let idle = UInt64(info[base + Int(CPU_STATE_IDLE)])
54 let nice = UInt64(info[base + Int(CPU_STATE_NICE)])
55 used += user + system + nice
56 total += user + system + nice + idle
57 }
58 return CPUTicks(used: used, total: total)
59}
60
61/// Aggregate CPU load sampler. Load is `Δbusy / Δtotal` between successive
62/// samples, so the first `sample()` primes and returns `nil`.
63public final class CPUSampler {
64 private var prev: CPUTicks?
65
66 public init() {}
67
68 /// Busy fraction in `[0, 1]` since the previous sample, or `nil` on the
69 /// priming call / if the counters are unavailable.
70 public func sample() -> Double? {
71 guard let cur = readCPUTicks() else { return nil }
72 defer { prev = cur }
73 guard let prev else { return nil }
74 let dTotal = cur.total >= prev.total ? cur.total - prev.total : 0
75 let dUsed = cur.used >= prev.used ? cur.used - prev.used : 0
76 guard dTotal > 0 else { return nil }
77 return min(1.0, Double(dUsed) / Double(dTotal))
78 }
79}
80
81/// A snapshot of the internal battery, if present.
82public struct BatteryInfo {
83 public var pct: Double?
84 public var charging: Bool?
85}
86
87/// Read battery percentage and charging state via IOKit power sources. No root.
88/// Returns empty fields on a machine with no internal battery (e.g. a desktop).
89public func readBattery() -> BatteryInfo {
90 guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
91 let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef]
92 else {
93 return BatteryInfo(pct: nil, charging: nil)
94 }
95
96 for source in list {
97 guard let desc = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any]
98 else { continue }
99 if let type = desc[kIOPSTypeKey] as? String, type != kIOPSInternalBatteryType {
100 continue
101 }
102 var pct: Double?
103 if let cur = (desc[kIOPSCurrentCapacityKey] as? NSNumber)?.doubleValue,
104 let max = (desc[kIOPSMaxCapacityKey] as? NSNumber)?.doubleValue, max > 0 {
105 pct = cur / max * 100.0
106 }
107 let charging = (desc[kIOPSIsChargingKey] as? NSNumber)?.boolValue
108 return BatteryInfo(pct: pct, charging: charging)
109 }
110 return BatteryInfo(pct: nil, charging: nil)
111}
112
113/// Instantaneous battery draw in watts from the IORegistry `AppleSmartBattery`
114/// node (Amperage in mA, Voltage in mV). No root. `nil` on a machine with no
115/// smart battery.
116public func readBatteryDrawW() -> Double? {
117 let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery"))
118 guard service != 0 else { return nil }
119 defer { IOObjectRelease(service) }
120
121 func number(_ key: String) -> Double? {
122 guard let cf = IORegistryEntryCreateCFProperty(service, key as CFString, kCFAllocatorDefault, 0)?
123 .takeRetainedValue() as? NSNumber
124 else { return nil }
125 return cf.doubleValue
126 }
127 guard let amperage = number("Amperage"), let voltage = number("Voltage") else { return nil }
128 // Amperage is signed (negative = discharging); draw magnitude is what we map.
129 return abs(amperage / 1000.0 * voltage / 1000.0)
130}
131
132/// Aggregate thermal state as `0..3` (nominal/fair/serious/critical). Sourced
133/// from `ProcessInfo`, not `powermetrics`; no root, no per-sensor detail.
134public func readThermalState() -> Double {
135 switch ProcessInfo.processInfo.thermalState {
136 case .nominal: return 0
137 case .fair: return 1
138 case .serious: return 2
139 case .critical: return 3
140 @unknown default: return 0
141 }
142}
143
144/// Collect one tick of hardware signals. Signals whose sensor is absent (no
145/// battery, priming CPU sample) are simply omitted never faked.
146public func collectHardwareSignals(cpu: CPUSampler, now: UInt64) -> [Signal] {
147 var out: [Signal] = []
148
149 if let load = cpu.sample() {
150 out.append(Signal(ts: now, source: .hardware, name: .cpuLoad, value: load))
151 }
152
153 let battery = readBattery()
154 if let pct = battery.pct {
155 out.append(Signal(ts: now, source: .macos, name: .batteryPct, value: pct))
156 }
157 if let charging = battery.charging {
158 out.append(Signal(ts: now, source: .macos, name: .charging, value: charging ? 1.0 : 0.0))
159 }
160 if let watts = readBatteryDrawW() {
161 out.append(Signal(ts: now, source: .hardware, name: .batteryDrawW, value: watts))
162 }
163
164 out.append(Signal(ts: now, source: .macos, name: .thermalState, value: readThermalState()))
165 return out
166}
macos-collector/Sources/CollectorCore/Wire.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2
3/// The wire schema version. **Must equal** `signal_schema::SCHEMA_VERSION` on the
4/// Rust side (v3 as of 0.3.0). A daemon rejects frames whose version it does not
5/// understand, so this is a hard cross-language contract.
6public let SCHEMA_VERSION: UInt16 = 3
7
8/// The collector domain. Discriminants **must match** Rust `Source::to_u8`.
9public enum Source: UInt8 {
10 case terminal = 0
11 case git = 1
12 case macos = 2
13 case hardware = 3
14}
15
16/// The enum-constrained metric name. Only the variants this collector emits are
17/// declared here; every discriminant **must match** Rust `SignalName::to_u8`.
18///
19/// The privacy boundary is unchanged for hardware: each of these is an aggregate
20/// `f64` scalar, and none of them carries a tag (all are emitted with
21/// `tag == nil`), exactly as `SignalName::allows_tag` is `false` for them.
22public enum SignalName: UInt8 {
23 case batteryPct = 17 // macos: battery charge percentage [0, 100]
24 case charging = 18 // macos: 1.0 charging, 0.0 not
25 case thermalState = 19 // macos: ProcessInfo thermal state 0..3
26 case batteryDrawW = 27 // hardware: instantaneous battery draw, watts
27 case cpuLoad = 28 // hardware: aggregate CPU busy fraction [0, 1]
28
29 /// Human-readable name, matching the Rust metric names (for `--once`).
30 public var label: String {
31 switch self {
32 case .batteryPct: return "battery_pct"
33 case .charging: return "charging"
34 case .thermalState: return "thermal_state"
35 case .batteryDrawW: return "battery_draw_w"
36 case .cpuLoad: return "cpu_load"
37 }
38 }
39}
40
41/// One flat signal record the Swift mirror of Rust `Signal`. The only payload
42/// channel is `value: Double` (`f64`); there is deliberately no content field.
43public struct Signal {
44 public let schemaVersion: UInt16
45 public let ts: UInt64
46 public let source: Source
47 public let name: SignalName
48 public let value: Double
49 public let tag: String?
50
51 public init(ts: UInt64, source: Source, name: SignalName, value: Double, tag: String? = nil) {
52 self.schemaVersion = SCHEMA_VERSION
53 self.ts = ts
54 self.source = source
55 self.name = name
56 self.value = value
57 self.tag = tag
58 }
59}
60
61private extension Array where Element == UInt8 {
62 /// Append a fixed-width integer in little-endian order (matches Rust
63 /// `to_le_bytes`).
64 mutating func appendLE<T: FixedWidthInteger>(_ v: T) {
65 var le = v.littleEndian
66 Swift.withUnsafeBytes(of: &le) { append(contentsOf: $0) }
67 }
68}
69
70/// Encode a signal to its length-prefixed wire frame, byte-for-byte identical to
71/// Rust `signal_schema::wire::encode`.
72///
73/// ```text
74/// [u32 body_len]
75/// body = [u16 schema_version][u64 ts][u8 source][u8 name][f64 value]
76/// [u8 tag_present] ( [u16 tag_len][tag_len bytes utf8] )?
77/// ```
78/// All multi-byte integers and the `f64` (via its IEEE-754 bit pattern) are
79/// little-endian.
80public func encode(_ s: Signal) -> [UInt8] {
81 var body: [UInt8] = []
82 body.appendLE(s.schemaVersion) // u16
83 body.appendLE(s.ts) // u64
84 body.append(s.source.rawValue) // u8
85 body.append(s.name.rawValue) // u8
86 body.appendLE(s.value.bitPattern) // f64 as its u64 bit pattern, LE
87 if let tag = s.tag {
88 body.append(1)
89 let bytes = Array(tag.utf8)
90 body.appendLE(UInt16(bytes.count))
91 body.append(contentsOf: bytes)
92 } else {
93 body.append(0)
94 }
95
96 var frame: [UInt8] = []
97 frame.appendLE(UInt32(body.count))
98 frame.append(contentsOf: body)
99 return frame
100}
101
102/// Unix milliseconds, best-effort (matches Rust `now_millis`).
103public func nowMillis() -> UInt64 {
104 UInt64(Date().timeIntervalSince1970 * 1000.0)
105}
macos-collector/Sources/macos-collector/main.swift added +119
@@ -0,0 +1,119 @@
1import Foundation
2import CollectorCore
3
4// The macOS IOKit hardware collector CLI. It reads aggregate hardware scalars
5// (IOKit-only, no root) and emits them as signal-schema wire frames the same
6// byte format signald already parses. It is one more *producer* for the shared
7// bus; it never subscribes and never reads user content.
8//
9// Modes:
10// (default) stream frames forever at --interval-ms (default 1000)
11// --once read one tick, print a human-readable summary to stderr,
12// and emit that tick's frames once
13// --hex emit each frame as a hex line (a bridge decodes; also handy
14// for eyeballing) instead of raw binary
15// --out <path> append raw frames to <path> (a spool the daemon can tail)
16// instead of stdout
17//
18// Wiring into signald: signald's live frame-ingest handshake is documented in
19// README.md and is intentionally minimal here (spec Phase 3 scope note). The
20// authoritative proof that signald parses these frames is the shared-byte
21// contract test on both sides (see WIRE section of the README).
22
23func fail(_ msg: String) -> Never {
24 FileHandle.standardError.write(Data("macos-collector: \(msg)\n".utf8))
25 exit(2)
26}
27
28func printUsage() {
29 let usage = """
30 macos-collector — macOS IOKit hardware collector (no root)
31
32 USAGE:
33 macos-collector [--once] [--hex] [--interval-ms <n>] [--out <path>]
34
35 Emits signal-schema wire frames for aggregate hardware signals:
36 cpu_load, battery_pct, charging, battery_draw_w, thermal_state
37 """
38 print(usage)
39}
40
41var once = false
42var hex = false
43var intervalMs: UInt64 = 1000
44var outPath: String?
45
46var args = CommandLine.arguments.dropFirst().makeIterator()
47while let arg = args.next() {
48 switch arg {
49 case "--once": once = true
50 case "--hex": hex = true
51 case "--interval-ms":
52 guard let v = args.next(), let n = UInt64(v) else { fail("--interval-ms needs a number") }
53 intervalMs = n
54 case "--out":
55 guard let p = args.next() else { fail("--out needs a path") }
56 outPath = p
57 case "-h", "--help":
58 printUsage()
59 exit(0)
60 default:
61 fail("unknown argument \(arg)")
62 }
63}
64
65/// Where frames go: a file (append) if --out was given, else stdout.
66func openOutput() -> FileHandle {
67 guard let path = outPath else { return FileHandle.standardOutput }
68 if !FileManager.default.fileExists(atPath: path) {
69 FileManager.default.createFile(atPath: path, contents: nil)
70 }
71 guard let handle = FileHandle(forWritingAtPath: path) else { fail("cannot open --out \(path)") }
72 handle.seekToEndOfFile()
73 return handle
74}
75
76func emit(_ signals: [Signal], to handle: FileHandle) {
77 for signal in signals {
78 let frame = encode(signal)
79 if hex {
80 let line = frame.map { String(format: "%02x", $0) }.joined() + "\n"
81 handle.write(Data(line.utf8))
82 } else {
83 handle.write(Data(frame))
84 }
85 }
86}
87
88func printSummary(_ signals: [Signal]) {
89 var lines = ["macos-collector --once (IOKit, no root):"]
90 for signal in signals {
91 let label = signal.name.label.padding(toLength: 16, withPad: " ", startingAt: 0)
92 lines.append(String(format: " %@ %.4f", label, signal.value))
93 }
94 if signals.first(where: { $0.name == .batteryPct }) == nil {
95 lines.append(" (no internal battery detected — battery signals omitted)")
96 }
97 FileHandle.standardError.write(Data((lines.joined(separator: "\n") + "\n").utf8))
98}
99
100let sampler = CPUSampler()
101let output = openOutput()
102
103if once {
104 _ = sampler.sample() // prime the CPU delta
105 Thread.sleep(forTimeInterval: 0.25)
106 let signals = collectHardwareSignals(cpu: sampler, now: nowMillis())
107 printSummary(signals)
108 emit(signals, to: output)
109 exit(0)
110}
111
112// Stream mode: prime, then emit a tick every interval, forever.
113_ = sampler.sample()
114FileHandle.standardError.write(Data("macos-collector: streaming every \(intervalMs) ms (IOKit, no root)\n".utf8))
115while true {
116 Thread.sleep(forTimeInterval: Double(intervalMs) / 1000.0)
117 let signals = collectHardwareSignals(cpu: sampler, now: nowMillis())
118 emit(signals, to: output)
119}
macos-collector/Tests/CollectorCoreTests/WireTests.swift added +66
@@ -0,0 +1,66 @@
1import XCTest
2@testable import CollectorCore
3
4final class WireTests: XCTestCase {
5 /// The canonical hardware frame, shared **verbatim** with the Rust decode
6 /// test (`crates/signal-schema/tests/hardware_wire.rs`). Both languages
7 /// independently commit to these exact bytes that shared literal *is* the
8 /// cross-language wire contract, so no cross-process execution is needed to
9 /// prove Swift and Rust agree.
10 ///
11 /// Signal: schema_version=3, ts=0, source=Hardware(3), name=CpuLoad(28),
12 /// value=0.5, tag=none.
13 func testCanonicalHardwareFrameMatchesRust() {
14 let signal = Signal(ts: 0, source: .hardware, name: .cpuLoad, value: 0.5)
15 let expected: [UInt8] = [
16 0x15, 0x00, 0x00, 0x00, // body_len = 21
17 0x03, 0x00, // schema_version = 3
18 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // ts = 0
19 0x03, // source = Hardware
20 0x1C, // name = CpuLoad (28)
21 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xE0, 0x3F, // value = 0.5 (f64 LE)
22 0x00, // tag_present = 0
23 ]
24 XCTAssertEqual(encode(signal), expected)
25 }
26
27 /// The schema version must equal the Rust `SCHEMA_VERSION`, or the daemon
28 /// drops every frame this collector sends.
29 func testSchemaVersionMatchesRust() {
30 XCTAssertEqual(SCHEMA_VERSION, 3)
31 }
32
33 /// The tagged-frame layout matches Rust too (hardware signals are untagged,
34 /// but the encoder must still agree on the audited-tag case).
35 func testTaggedFrameLayout() {
36 let signal = Signal(ts: 0, source: .macos, name: .batteryPct, value: 1.0, tag: "ab")
37 let frame = encode(signal)
38 // body = 2+8+1+1+8+1 + (2 + 2) = 25 ; frame = 4 + 25 = 29
39 XCTAssertEqual(frame.count, 29)
40 XCTAssertEqual(Array(frame.prefix(4)), [0x19, 0x00, 0x00, 0x00]) // body_len = 25
41 XCTAssertEqual(frame[24], 0x01) // tag_present
42 XCTAssertEqual([frame[25], frame[26]], [0x02, 0x00]) // tag_len = 2
43 XCTAssertEqual([frame[27], frame[28]], Array("ab".utf8))
44 }
45
46 /// Hardware signals never carry a tag and never carry content the payload
47 /// is a single `Double`. This mirrors the Rust privacy invariant.
48 func testHardwareSignalsAreUntaggedScalars() {
49 for name in [SignalName.cpuLoad, .batteryPct, .charging, .batteryDrawW, .thermalState] {
50 let s = Signal(ts: 1, source: .hardware, name: name, value: 0.0)
51 XCTAssertNil(s.tag)
52 }
53 }
54
55 /// The IOKit CPU read yields a plausible fraction in `[0, 1]` (or nil while
56 /// unavailable) a real read against real hardware.
57 func testCPUSamplerInRange() {
58 let sampler = CPUSampler()
59 _ = sampler.sample()
60 Thread.sleep(forTimeInterval: 0.1)
61 if let load = sampler.sample() {
62 XCTAssertGreaterThanOrEqual(load, 0.0)
63 XCTAssertLessThanOrEqual(load, 1.0)
64 }
65 }
66}
shell-hooks/README.md added +33
@@ -0,0 +1,33 @@
1# shell-hooks
2
3Shell side of the terminal collector (spec §1.4). **Live as of v0.2.** These
4snippets belong in the dotfiles repo and are sourced by the user's `.zshrc`;
5they are kept here so the privacy contract lives next to the code it constrains
6(and so the differential secret-typing test can drive the real file).
7
8## The one rule
9
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.
12
13- No input tap, no `CGEventTap`, no PTY sniffing.
14- Never reference the `zle` line buffer or capture argv.
15- What leaves the shell is a number, flushed on `precmd`.
16
17v0.2 ships the `zle` keypress counter: a widget wraps `self-insert`, does
18`(( _SIGNALD_KEYS++ ))`, then calls the built-in insert. It receives the key in
19the editor and discards it — the character is never assigned to a variable that
20outlives the widget and never leaves the shell. On each `precmd` the hook
21appends one count record — `<epoch_ms> <keys> <session_seconds>`, numbers only
22— to `$SIGNALD_SPOOL`, which `signald` reads.
23
24This contract is enforced by the forbidden-symbol scan **and** the differential
25secret-typing test in `crates/signal-schema/tests/privacy_invariant.rs` (plus
26the full-pipeline gate in `crates/signald/tests/`), which drive *this file* with
27a planted secret and fail the build if it — in any encoding — reaches the spool,
28the wire, or SQLite.
29
30## Files
31
32- `signald-hooks.zsh` — the `zle` keystroke counter + `precmd` count flush
33 (aggregate-only).
shell-hooks/signald-hooks.zsh added +60
@@ -0,0 +1,60 @@
1# signald-hooks.zsh — terminal collector, shell side (spec §1.4).
2#
3# A small sourced script that lives in the dotfiles repo and is sourced by the
4# user's .zshrc. It talks to signald by appending newline-delimited AGGREGATE
5# COUNT records to a spool file that signald reads. It holds no history.
6#
7# ============================ PRIVACY CONTRACT ============================
8# AGGREGATE-ONLY. This script emits COUNTS, DURATIONS, and EXIT CODES.
9# It NEVER reads, stores, or transmits the content of a command or a keystroke.
10#
11# - No input tap (none of the global event-tap / HID keyboard APIs). No
12# PTY sniffing.
13# - The keypress counter is a zle widget that increments a NUMBER and then
14# calls the built-in insert. It receives the key in the editor and discards
15# it; the character is never assigned to a variable that outlives the widget
16# and never leaves the shell. What leaves is a count.
17# - It NEVER references the zle line buffer (the BUFFER/LBUFFER/RBUFFER zle
18# parameters) and NEVER captures argv. The forbidden-symbol CI scan
19# (crates/signal-schema/tests/privacy_invariant.rs) fails the build if it
20# ever does. That test — plus the differential secret-typing test — drives
21# THIS FILE with a planted secret and asserts the secret never reaches the
22# spool, the wire, or SQLite.
23#
24# Spool record format (all fields are NUMBERS, space-separated):
25#
26# <epoch_ms> <keys_since_last_flush> <session_seconds>
27#
28# One record is appended on each precmd (i.e. after each command line). There is
29# no field capable of carrying typed content.
30# =========================================================================
31
32zmodload zsh/datetime 2>/dev/null
33
34# Spool the daemon reads. Override SIGNALD_SPOOL to point elsewhere.
35: ${SIGNALD_SPOOL:=${XDG_RUNTIME_DIR:-$HOME/.local/state/signald}/terminal.spool}
36
37# --- session start: a timestamp only ---
38typeset -g _SIGNALD_SESSION_START=${EPOCHSECONDS:-0}
39typeset -g _SIGNALD_KEYS=0
40
41# Keypress counter: increment a number, then perform the normal insert. The key
42# is handled by `.self-insert` and is never captured here. No BUFFER/LBUFFER.
43_signald_self_insert() {
44 (( _SIGNALD_KEYS++ ))
45 zle .self-insert
46}
47zle -N self-insert _signald_self_insert
48
49# precmd: the previous command finished. Append ONE aggregate record (numbers
50# only) and reset the per-flush key counter.
51_signald_precmd() {
52 local now_ms=$(( ${EPOCHREALTIME:-$EPOCHSECONDS} * 1000 ))
53 local session=$(( ${EPOCHSECONDS:-0} - _SIGNALD_SESSION_START ))
54 mkdir -p ${SIGNALD_SPOOL:h} 2>/dev/null
55 print -r -- "${now_ms%.*} ${_SIGNALD_KEYS} ${session}" >> $SIGNALD_SPOOL
56 _SIGNALD_KEYS=0
57}
58
59autoload -Uz add-zsh-hook
60add-zsh-hook precmd _signald_precmd