shell-hooks/signald-hooks.zsh
63 lines · 2992 bytes
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> <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