Strip references to the uncommitted spec and fix doc drift !8
16 files changed, +51 −53
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 |