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

shell-hooks/signald-hooks.zsh

63 lines · 2983 bytes

 1# signald-hooks.zsh — terminal collector, shell side.
 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 a KEYSTROKE COUNT and a SESSION DURATION.
 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 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> <session_id>
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. session_id is this shell's pid,
30# so the daemon keeps each shell's rate separate when several append to the
31# same spool. The daemon consumes the spool (renames it aside and deletes it),
32# which is why every write opens the file afresh with >>.
33# =========================================================================
34
35zmodload zsh/datetime 2>/dev/null
36
37# Spool the daemon reads. Override SIGNALD_SPOOL to point elsewhere.
38: ${SIGNALD_SPOOL:=${XDG_RUNTIME_DIR:-$HOME/.local/state/signald}/terminal.spool}
39
40# --- session start: a timestamp only ---
41typeset -g _SIGNALD_SESSION_START=${EPOCHSECONDS:-0}
42typeset -g _SIGNALD_KEYS=0
43
44# Keypress counter: increment a number, then perform the normal insert. The key
45# is handled by `.self-insert` and is never captured here. No BUFFER/LBUFFER.
46_signald_self_insert() {
47  (( _SIGNALD_KEYS++ ))
48  zle .self-insert
49}
50zle -N self-insert _signald_self_insert
51
52# precmd: the previous command finished. Append ONE aggregate record (numbers
53# only) and reset the per-flush key counter.
54_signald_precmd() {
55  local now_ms=$(( ${EPOCHREALTIME:-$EPOCHSECONDS} * 1000 ))
56  local session=$(( ${EPOCHSECONDS:-0} - _SIGNALD_SESSION_START ))
57  mkdir -p ${SIGNALD_SPOOL:h} 2>/dev/null
58  print -r -- "${now_ms%.*} ${_SIGNALD_KEYS} ${session} $$" >> $SIGNALD_SPOOL
59  _SIGNALD_KEYS=0
60}
61
62autoload -Uz add-zsh-hook
63add-zsh-hook precmd _signald_precmd