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

81 lines · 3654 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# Largest spool we will keep. The daemon consumes the spool every tick, so this
55# only fills up while it is not running. Records are worth nothing once the
56# daemon has been away for long — the sessions they describe fall outside its
57# active window anyway — so the file is truncated rather than rotated.
58: ${SIGNALD_SPOOL_MAX_BYTES:=1048576}
59
60zmodload -F zsh/stat b:zstat 2>/dev/null
61
62_signald_spool_too_big() {
63  local -a st
64  zstat -A st +size $SIGNALD_SPOOL 2>/dev/null || return 1
65  (( st[1] > SIGNALD_SPOOL_MAX_BYTES ))
66}
67
68_signald_precmd() {
69  local now_ms=$(( ${EPOCHREALTIME:-$EPOCHSECONDS} * 1000 ))
70  local session=$(( ${EPOCHSECONDS:-0} - _SIGNALD_SESSION_START ))
71  mkdir -p ${SIGNALD_SPOOL:h} 2>/dev/null
72  # zstat rather than `wc -c`: this runs at every prompt and must not fork.
73  if _signald_spool_too_big; then
74    : > $SIGNALD_SPOOL
75  fi
76  print -r -- "${now_ms%.*} ${_SIGNALD_KEYS} ${session} $$" >> $SIGNALD_SPOOL
77  _SIGNALD_KEYS=0
78}
79
80autoload -Uz add-zsh-hook
81add-zsh-hook precmd _signald_precmd