Commit 2ccb0bd1d1
2ccb0bd1d1b2d4ced1cb1036f441a79dd3a74121
parent: b3a7c31400
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-04 14:19 UTC
Strip references to the uncommitted spec and fix doc drift
The spec is not in the repo and never was, so 34 "spec §N" citations across 16
files pointed nowhere. Each is replaced with the rule it referred to, stated
plainly, or dropped where the surrounding sentence already said it.
Drift fixed:
- privacy_invariant.rs described the differential secret-typing test as a
stubbed #[ignore] landing in Phase 2. It is active and is the ship gate.
- macos-collector/README.md pointed at a captured run in the project README
that does not exist; it now names the command that prints real frames.
- The shell hooks claimed to emit exit codes in both the README and the
script's own privacy contract. They emit a keystroke count and a session
duration.
- Two stale "CI" mentions, left over from removing .gitbay/ci.yml.
The fourth drift item in the issue, signald's module docs calling the hardware
collector a stub, was already corrected when the ingest work landed.
Closes #7
README.md
+2 −2
| @@ -33,7 +33,7 @@ non-toy outputs (e-ink dashboards, printed posters). |
| 33 | 33 | Reading typing during real work is a keylogger unless it is *structurally* |
| 34 | 34 | content-free — and unless that is a **test**, not a sentence in a README. |
| 35 | 35 | |
| 36 | | The invariant (spec §1.5): |
| 36 | The invariant: |
| 37 | 37 | |
| 38 | 38 | > No process persists, transmits, or exposes any representation from which the |
| 39 | 39 | > content or identity of an individual keystroke, command argument, or typed |
| @@ -144,7 +144,7 @@ ambient-companions/ |
| 144 | 144 | |
| 145 | 145 | ### Language note |
| 146 | 146 | |
| 147 | | The daemon is written in **Rust**. The spec allows Rust or Go; Rust is chosen |
| 147 | The daemon is written in **Rust**, chosen |
| 148 | 148 | because it makes the "no content field exists" guarantee enforceable in the |
| 149 | 149 | type system (the `f64`-only `Value` payload), which is the whole point of the |
| 150 | 150 | privacy boundary. |
crates/signal-schema/src/lib.rs
+2 −3
| @@ -4,7 +4,7 @@ |
| 4 | 4 | //! |
| 5 | 5 | //! ## The privacy boundary, made structural |
| 6 | 6 | //! |
| 7 | | //! Everything in this suite is a footnote to one invariant (spec §1.5): |
| 7 | //! Everything in this suite is a footnote to one invariant: |
| 8 | 8 | //! |
| 9 | 9 | //! > No process persists, transmits, or exposes any representation from which |
| 10 | 10 | //! > the content or identity of an individual keystroke, command argument, or |
| @@ -235,8 +235,7 @@ impl Signal { |
| 235 | 235 | } |
| 236 | 236 | } |
| 237 | 237 | |
| 238 | | /// Length-prefixed wire encoding (spec §1.3: "length-prefixed framing over the |
| 239 | | /// Unix socket"). |
| 238 | /// Length-prefixed wire encoding, the framing used over the Unix socket. |
| 240 | 239 | /// |
| 241 | 240 | /// The frame is a little-endian `u32` body length followed by the body: |
| 242 | 241 | /// |
crates/signal-schema/tests/privacy_invariant.rs
+11 −11
| @@ -1,10 +1,10 @@ |
| 1 | | //! # The privacy invariant test (spec §1.5) |
| 1 | //! # The privacy invariant test |
| 2 | 2 | //! |
| 3 | 3 | //! This is the single most important test in the project. It exists from day |
| 4 | 4 | //! one, before the sensitive terminal collector does, so the guardrail is in |
| 5 | 5 | //! place before the code it guards. |
| 6 | 6 | //! |
| 7 | | //! The invariant (spec §1.5): |
| 7 | //! The invariant: |
| 8 | 8 | //! |
| 9 | 9 | //! > No process persists, transmits, or exposes any representation from which |
| 10 | 10 | //! > the content or identity of an individual keystroke, command argument, or |
| @@ -17,12 +17,12 @@ |
| 17 | 17 | //! content-carrying field (only `tag: Option<Tag>` is a string, and it is |
| 18 | 18 | //! the audited identifier exception). |
| 19 | 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. |
| 20 | //! keylogger APIs / shell patterns (the static gate). |
| 21 | //! 4. `differential_secret_typing` — the runtime gate: drive the real hooks |
| 22 | //! with a planted secret and assert it never reaches the spool or the |
| 23 | //! wire in any encoding. Active. The full-pipeline version, through the |
| 24 | //! collector, the history store and the hub, is |
| 25 | //! `signald/tests/differential_secret_typing.rs`. |
| 26 | 26 | |
| 27 | 27 | use std::mem::size_of; |
| 28 | 28 | use std::path::PathBuf; |
| @@ -77,7 +77,7 @@ fn wire_format_has_no_content_field() { |
| 77 | 77 | ); |
| 78 | 78 | } |
| 79 | 79 | |
| 80 | | /// The static CI gate (spec §1.5): the tree must contain none of the APIs a |
| 80 | /// The static gate: the tree must contain none of the APIs a |
| 81 | 81 | /// real keylogger would use, nor any shell hook that touches the line buffer. |
| 82 | 82 | /// Any hit fails the build. The banned list itself lives here and is reviewed. |
| 83 | 83 | #[test] |
| @@ -134,8 +134,8 @@ fn forbidden_symbol_scan() { |
| 134 | 134 | ); |
| 135 | 135 | } |
| 136 | 136 | |
| 137 | | /// The differential secret-typing acceptance test (spec §1.5, §5 Phase 2) — |
| 138 | | /// the ship gate for the terminal collector, now **active**. |
| 137 | /// The differential secret-typing acceptance test — the ship gate for the |
| 138 | /// terminal collector. |
| 139 | 139 | /// |
| 140 | 140 | /// This drives the **real production hook** (`shell-hooks/signald-hooks.zsh`) |
| 141 | 141 | /// through a **real interactive zsh under a real pseudo-terminal** (zsh's own |
crates/signald/src/history.rs
+1 −1
| @@ -1,4 +1,4 @@ |
| 1 | | //! # History store (spec §1.3, §4) |
| 1 | //! # History store |
| 2 | 2 | //! |
| 3 | 3 | //! A local SQLite (WAL) store of a rolling history of signals. History is what |
| 4 | 4 | //! lets a renderer read *recent aggregates* rather than only a live snapshot — |
crates/signald/src/hub.rs
+2 −2
| @@ -1,10 +1,10 @@ |
| 1 | | //! # The signal hub — last-value cache + live fan-out (spec §1.3) |
| 1 | //! # The signal hub — last-value cache + live fan-out |
| 2 | 2 | //! |
| 3 | 3 | //! The in-memory heart of the publish side. Collectors [`Hub::publish`] signals; |
| 4 | 4 | //! subscribers [`Hub::subscribe`] and receive the current value of every cached |
| 5 | 5 | //! metric immediately, then a live stream of updates. |
| 6 | 6 | //! |
| 7 | | //! Subscription semantics (spec §1.3): *last-value-cache + live stream.* On |
| 7 | //! Subscription semantics: *last-value-cache + live stream.* On |
| 8 | 8 | //! subscribe, the caller is handed the latest value of each `name` (keyed by |
| 9 | 9 | //! name + audited tag), so a renderer paints correct state at once; thereafter |
| 10 | 10 | //! it receives updates as they are published. Channels are unbounded, so a slow |
crates/signald/src/lib.rs
+8 −8
| @@ -34,11 +34,11 @@ pub fn tag_within_roots(sig: &Signal, roots: &[PathBuf]) -> bool { |
| 34 | 34 | roots.iter().any(|root| path.starts_with(root)) |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | | /// Collectors: each reduces its domain to schema scalars (spec §1.1, §1.4). |
| 37 | /// Collectors: each reduces its domain to schema scalars. |
| 38 | 38 | pub mod collectors { |
| 39 | 39 | /// Git collector: shell out to `git` for counts and ages. Counts and |
| 40 | 40 | /// branch/age scalars only — never diff content. This is the cleanest |
| 41 | | /// signal in the suite and the first collector wired (spec §2.2, §3). |
| 41 | /// signal in the suite and the first collector wired. |
| 42 | 42 | pub mod git { |
| 43 | 43 | use std::path::Path; |
| 44 | 44 | use std::process::Command; |
| @@ -52,7 +52,7 @@ pub mod collectors { |
| 52 | 52 | |
| 53 | 53 | /// Derive the aggregate git signals for one repo. Every value is a |
| 54 | 54 | /// scalar `f64`; the only string on the wire is the repo path carried |
| 55 | | /// in the audited `tag` (spec §1.2). Returns an empty vec if `repo` is |
| 55 | /// in the audited `tag`. Returns an empty vec if `repo` is |
| 56 | 56 | /// not a git repo. |
| 57 | 57 | pub fn collect(repo: &Path) -> Vec<Signal> { |
| 58 | 58 | if !is_git_repo(repo) { |
| @@ -278,7 +278,7 @@ pub mod collectors { |
| 278 | 278 | source: Source::Terminal, |
| 279 | 279 | name, |
| 280 | 280 | value: Value(value), |
| 281 | | tag: None, // terminal aggregates are never tagged (spec §1.2) |
| 281 | tag: None, // terminal aggregates are never tagged |
| 282 | 282 | }; |
| 283 | 283 | vec![ |
| 284 | 284 | mk(SignalName::KeysPerMin, keys_per_min), |
| @@ -363,7 +363,7 @@ pub mod collectors { |
| 363 | 363 | } |
| 364 | 364 | |
| 365 | 365 | /// System + hardware collector: **IOKit-only, no powermetrics, no root** |
| 366 | | /// (spec §1.4). Native macOS. |
| 366 | /// Native macOS. |
| 367 | 367 | /// |
| 368 | 368 | /// The reads live out of process in the sibling Swift package |
| 369 | 369 | /// `macos-collector/` (SwiftPM, not in this cargo workspace), which writes |
| @@ -436,13 +436,13 @@ pub mod collectors { |
| 436 | 436 | } |
| 437 | 437 | } |
| 438 | 438 | |
| 439 | | /// The publish side (spec §1.3): a live pub/sub fan-out with a last-value cache. |
| 439 | /// The publish side: a live pub/sub fan-out with a last-value cache. |
| 440 | 440 | /// |
| 441 | 441 | /// On connect a subscriber is handed the current value of every cached `name` |
| 442 | 442 | /// immediately (so a renderer paints correct state at once), then streams |
| 443 | 443 | /// updates as they change. Each subscriber runs on its own thread with its own |
| 444 | | /// unbounded channel, so a slow subscriber never blocks the daemon (spec §1.3: |
| 445 | | /// "a stuck wallpaper process must not stall the audio thread's feed"). |
| 444 | /// unbounded channel, so a slow subscriber never blocks the daemon: one stuck |
| 445 | /// renderer must not stall any other subscriber's feed. |
| 446 | 446 | pub mod publish { |
| 447 | 447 | use std::io; |
| 448 | 448 | use std::io::Write; |
crates/signald/src/main.rs
+2 −2
| @@ -2,7 +2,7 @@ |
| 2 | 2 | //! |
| 3 | 3 | //! The one long-lived, user-level daemon that owns every collector and |
| 4 | 4 | //! publishes a signal stream. Renderers are thin subscribers over a local Unix |
| 5 | | //! socket — no renderer ever touches a sensor (spec §1.3). |
| 5 | //! socket — no renderer ever touches a sensor. |
| 6 | 6 | //! |
| 7 | 7 | //! ```text |
| 8 | 8 | //! zsh hooks ───▶ terminal collector ─┐ (aggregate counts from the spool) |
| @@ -167,7 +167,7 @@ fn default_socket_path() -> PathBuf { |
| 167 | 167 | |
| 168 | 168 | /// Log enabled collectors and assert none holds an input-tap capability. A real |
| 169 | 169 | /// keylogger would need one of the forbidden APIs; their absence is the point, |
| 170 | | /// and this is the runtime half of that guarantee (spec §1.5). |
| 170 | /// and this is the runtime half of that guarantee. |
| 171 | 171 | fn print_self_attestation(cfg: &Config) { |
| 172 | 172 | eprintln!("signald {} — self-attestation", env!("CARGO_PKG_VERSION")); |
| 173 | 173 | eprintln!(" transport: unix socket (length-prefixed frames), live pub/sub"); |
crates/signald/tests/differential_secret_typing.rs
+2 −2
| @@ -1,4 +1,4 @@ |
| 1 | | //! # The differential secret-typing test — the privacy ship-gate (spec §1.5, §5) |
| 1 | //! # The differential secret-typing test — the privacy ship-gate |
| 2 | 2 | //! |
| 3 | 3 | //! This is the acceptance test for the whole terminal collector, and the single |
| 4 | 4 | //! most important test in the project. It drives the **real production hook** |
| @@ -150,7 +150,7 @@ zpty -d SH 2>/dev/null |
| 150 | 150 | |
| 151 | 151 | // --- encodings + scanning --- |
| 152 | 152 | |
| 153 | | /// The secret in every representation the spec calls out (plain, reversed, hex, |
| 153 | /// The secret in every representation worth checking (plain, reversed, hex, |
| 154 | 154 | /// base64), as raw byte needles to search for. |
| 155 | 155 | fn secret_encodings(secret: &str) -> Vec<(&'static str, Vec<u8>)> { |
| 156 | 156 | let b = secret.as_bytes(); |
crates/signald/tests/git_collector.rs
+1 −1
| @@ -1,4 +1,4 @@ |
| 1 | | //! Git collector acceptance test (spec §5 Phase 1). |
| 1 | //! Git collector acceptance test. |
| 2 | 2 | //! |
| 3 | 3 | //! Builds a real temp git repo, drives the collector against it, and asserts the |
| 4 | 4 | //! aggregate scalars — commits-in-window, commits-today, branch count, and |
crates/signald/tests/streaming.rs
+1 −1
| @@ -1,4 +1,4 @@ |
| 1 | | //! Live-streaming acceptance test (spec §1.3): a subscriber gets the current |
| 1 | //! Live-streaming acceptance test: a subscriber gets the current |
| 2 | 2 | //! value of each metric immediately on connect (last-value cache), then receives |
| 3 | 3 | //! updates as values change — over the real Unix socket, not just the hub. |
| 4 | 4 | |
crates/terminal-garden/src/lib.rs
+2 −3
| @@ -6,7 +6,7 @@ |
| 6 | 6 | //! |
| 7 | 7 | //! Each watched repo is a plot; growth stage is a function of recent commit |
| 8 | 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 |
| 9 | //! A plant advances 🌱→🌿→🌳 as commits land, and droops then |
| 10 | 10 | //! browns as a repo goes stale. |
| 11 | 11 | |
| 12 | 12 | use std::collections::BTreeMap; |
| @@ -148,8 +148,7 @@ pub fn plots_from_signals(signals: &[Signal]) -> Vec<Plot> { |
| 148 | 148 | by_repo.into_values().collect() |
| 149 | 149 | } |
| 150 | 150 | |
| 151 | | /// Render the whole garden to a text block (spec §2.2: a text render is fine for |
| 152 | | /// v0.1). |
| 151 | /// Render the whole garden to a text block. |
| 153 | 152 | pub fn render(plots: &[Plot]) -> String { |
| 154 | 153 | let mut out = String::new(); |
| 155 | 154 | out.push_str("terminal-garden\n"); |
crates/terminal-garden/src/main.rs
+2 −2
| @@ -1,6 +1,6 @@ |
| 1 | 1 | //! # terminal-garden |
| 2 | 2 | //! |
| 3 | | //! The first face (spec §3: "the garden is the recommended first build"). |
| 3 | //! The first face of the bus: a renderer, not a collector. |
| 4 | 4 | //! Git is the cleanest signal in the suite — discrete, unambiguous, no privacy |
| 5 | 5 | //! questions — so the garden proves the bus before anything reads the shell. |
| 6 | 6 | //! |
| @@ -25,7 +25,7 @@ use std::path::PathBuf; |
| 25 | 25 | use signal_schema::{wire, Signal, SignalName}; |
| 26 | 26 | use terminal_garden::{plots_from_signals, render}; |
| 27 | 27 | |
| 28 | | /// The `name`s the garden cares about (spec §2.2 — the git aggregates). |
| 28 | /// The `name`s the garden cares about: the git aggregates. |
| 29 | 29 | const SUBSCRIBE: &[SignalName] = &[ |
| 30 | 30 | SignalName::CommitsWindow, |
| 31 | 31 | SignalName::CommitsToday, |
macos-collector/README.md
+5 −5
| @@ -28,14 +28,14 @@ swift run macos-collector --once # one real IOKit read (summary on stderr) |
| 28 | 28 | |
| 29 | 29 | **Intentionally omitted:** GPU utilization and fan RPM. They are reachable in |
| 30 | 30 | principle via `IOReport`/AppleSMC, but only through chip-generation-specific, |
| 31 | | undocumented channels. Per spec §1.4/§6, a metric that cannot be read cleanly |
| 32 | | without root is **omitted from the schema rather than gated behind sudo**. |
| 31 | undocumented channels. A metric that cannot be read cleanly without root is |
| 32 | **omitted from the schema rather than gated behind sudo**. |
| 33 | 33 | Battery signals are omitted on a machine with no internal battery (a desktop). |
| 34 | 34 | |
| 35 | 35 | Every value is an **aggregate scalar**. This collector has no code path that can |
| 36 | 36 | read user content: it touches only hardware counters and power registers. The |
| 37 | 37 | `f64`-only payload and the absence of any input-tap API mean the privacy |
| 38 | | invariant (spec §1.5) holds for hardware signals exactly as it does for the |
| 38 | invariant holds for hardware signals exactly as it does for the |
| 39 | 39 | terminal collector — and the repo's `forbidden_symbol_scan` gate scans these |
| 40 | 40 | `.swift` files too. |
| 41 | 41 | |
| @@ -94,8 +94,8 @@ This identical literal is asserted in **both**: |
| 94 | 94 | |
| 95 | 95 | Because both sides independently commit to the same bytes, cross-*process* |
| 96 | 96 | execution 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 |
| 98 | | byte-for-byte to this layout; see the project README for a captured run.) |
| 97 | (`swift run macos-collector --once --hex` prints real frames in this layout if |
| 98 | you want to check by hand.) |
| 99 | 99 | |
| 100 | 100 | ## Wiring into signald |
| 101 | 101 | |
macos-collector/Sources/CollectorCore/Hardware.swift
+4 −4
| @@ -2,7 +2,7 @@ import Foundation |
| 2 | 2 | import IOKit |
| 3 | 3 | import IOKit.ps |
| 4 | 4 | |
| 5 | | // The hardware reads. IOKit-only, no `powermetrics`, no root (spec §1.4, §6): |
| 5 | // The hardware reads. IOKit-only, no `powermetrics`, no root: |
| 6 | 6 | // * CPU load — Mach `host_processor_info(PROCESSOR_CPU_LOAD_INFO)` deltas |
| 7 | 7 | // * battery % — IOKit power sources (`IOPSCopyPowerSourcesInfo`) |
| 8 | 8 | // * charging — IOKit power sources |
| @@ -10,9 +10,9 @@ import IOKit.ps |
| 10 | 10 | // * thermal — `ProcessInfo.thermalState` (aggregate enum, no root) |
| 11 | 11 | // |
| 12 | 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. |
| 13 | // but only with chip-generation-specific, undocumented channel spelunking. A |
| 14 | // metric that cannot be read cleanly without root is *omitted from the schema* |
| 15 | // rather than gated behind sudo. They are intentionally absent here. |
| 16 | 16 | // |
| 17 | 17 | // Every value produced is an aggregate scalar. There is no code path here that |
| 18 | 18 | // reads user content — this file cannot, because it only ever touches hardware |
shell-hooks/README.md
+3 −3
| @@ -1,14 +1,14 @@ |
| 1 | 1 | # shell-hooks |
| 2 | 2 | |
| 3 | | Shell side of the terminal collector (spec §1.4). **Live as of v0.2.** These |
| 3 | Shell side of the terminal collector. **Live as of v0.2.** These |
| 4 | 4 | snippets belong in the dotfiles repo and are sourced by the user's `.zshrc`; |
| 5 | 5 | they are kept here so the privacy contract lives next to the code it constrains |
| 6 | 6 | (and so the differential secret-typing test can drive the real file). |
| 7 | 7 | |
| 8 | 8 | ## The one rule |
| 9 | 9 | |
| 10 | | **Aggregate-only.** The hooks emit counts, durations, and exit codes. They |
| 11 | | never read, store, or transmit the content of a command or a keystroke. |
| 10 | **Aggregate-only.** The hooks emit a keystroke count and a session duration. |
| 11 | They never read, store, or transmit the content of a command or a keystroke. |
| 12 | 12 | |
| 13 | 13 | - No input tap, no `CGEventTap`, no PTY sniffing. |
| 14 | 14 | - Never reference the `zle` line buffer or capture argv. |
shell-hooks/signald-hooks.zsh
+3 −3
| @@ -1,11 +1,11 @@ |
| 1 | | # signald-hooks.zsh — terminal collector, shell side (spec §1.4). |
| 1 | # signald-hooks.zsh — terminal collector, shell side. |
| 2 | 2 | # |
| 3 | 3 | # A small sourced script that lives in the dotfiles repo and is sourced by the |
| 4 | 4 | # user's .zshrc. It talks to signald by appending newline-delimited AGGREGATE |
| 5 | 5 | # COUNT records to a spool file that signald reads. It holds no history. |
| 6 | 6 | # |
| 7 | 7 | # ============================ PRIVACY CONTRACT ============================ |
| 8 | | # AGGREGATE-ONLY. This script emits COUNTS, DURATIONS, and EXIT CODES. |
| 8 | # AGGREGATE-ONLY. This script emits a KEYSTROKE COUNT and a SESSION DURATION. |
| 9 | 9 | # It NEVER reads, stores, or transmits the content of a command or a keystroke. |
| 10 | 10 | # |
| 11 | 11 | # - No input tap (none of the global event-tap / HID keyboard APIs). No |
| @@ -15,7 +15,7 @@ |
| 15 | 15 | # it; the character is never assigned to a variable that outlives the widget |
| 16 | 16 | # and never leaves the shell. What leaves is a count. |
| 17 | 17 | # - It NEVER references the zle line buffer (the BUFFER/LBUFFER/RBUFFER zle |
| 18 | | # parameters) and NEVER captures argv. The forbidden-symbol CI scan |
| 18 | # parameters) and NEVER captures argv. The forbidden-symbol scan |
| 19 | 19 | # (crates/signal-schema/tests/privacy_invariant.rs) fails the build if it |
| 20 | 20 | # ever does. That test — plus the differential secret-typing test — drives |
| 21 | 21 | # THIS FILE with a planted secret and asserts the secret never reaches the |