#!/bin/sh # meathook Claude Code hook — the audited surface (spec §11). # # EVERYTHING this script can send is listed here (spec §8 allowlist): # event type, event_id, occurred_at, session_key, # machine / project / repo / worktree / branch labels, # harness name, model name, tool name (on tool_error), # coalesced activity count. # It NEVER sends: file contents, diffs, prompt text, model output, command # arguments, error text, environment variables. # # TWO opt-in exceptions (spec §8 verbosity), both off by default and both # passed through the scrub() below — read it before enabling either: # "prompt_summaries": "true" — the first substantial prompt of a session # is sent as its summary (UserPromptSubmit case). # "reply_summaries": "true" — the agent's closing message replaces the # summary at each turn end (Stop case). # # Wire-up: every hook invokes this script with the hook name as argv[1] and # the hook JSON on stdin (see settings-snippet.json). Dependencies: curl, # git, uuidgen, shasum or sha256sum. set -u HOOK="${1:-}" STDIN=$(cat) # Config: env vars beat the per-worktree file, which beats the global file. conf() { [ -f "$2" ] && sed -n 's/.*"'"$1"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$2" | head -n 1; } PROJ_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" cfg() { v=$(conf "$1" "$PROJ_DIR/.meathook.json"); [ -n "$v" ] || v=$(conf "$1" "$HOME/.meathook/config.json") printf '%s' "$v" } URL="${MEATHOOK_URL:-$(cfg url)}" TOKEN="${MEATHOOK_TOKEN:-$(cfg token)}" [ -n "$URL" ] && [ -n "$TOKEN" ] || exit 0 # not configured: never break the agent # First string value for a top-level stdin key. Top-level keys serialize # before nested tool payloads, so the FIRST occurrence is the right one — # grep -o emits matches in order (greedy sed would take the last, letting a # nested tool_input smuggle in a fake session_id). # # ([^"\\]|\\.)* rather than [^"]*: a JSON string ends at the first UNESCAPED # quote, and an assistant message that says Fixed the "foo" test arrives as # \"foo\". Matching [^"]* stopped dead at that backslash-quote and silently # truncated the summary there. jget() { printf '%s' "$STDIN" | grep -oE '"'"$1"'"[[:space:]]*:[[:space:]]*"([^"\\]|\\.)*"' | head -n 1 | sed -E 's/^"[^"]*"[[:space:]]*:[[:space:]]*"//; s/"$//' } SESSION_ID=$(jget session_id) [ -n "$SESSION_ID" ] || exit 0 # The complete transformation applied to any opt-in text fragment before it # can leave the machine (§8): escape sequences and whitespace runs become # single spaces, quotes and backslashes are dropped, unbroken token-shaped # runs of 28+ chars are redacted, invalid UTF-8 is stripped, 140-char cap. # \uXXXX goes to a space rather than through the backslash strip, which would # otherwise put the literal text "u0022" on the board — this reads the raw # JSON, so it never sees a decoded string (the PowerShell client parses the # JSON properly and so cannot produce that litter). scrub() { sed -E 's/\\[nrt]/ /g; s/\\u[0-9a-fA-F]{4}/ /g' | tr -d '"\\' | sed -E 's/[A-Za-z0-9+\/_-]{28,}/[redacted]/g; s/ +/ /g' | cut -c1-140 | iconv -c -f UTF-8 -t UTF-8 } STATE_DIR="$HOME/.meathook/state" mkdir -p "$STATE_DIR" STATE="$STATE_DIR/$SESSION_ID" MODEL="" TYPE="" PAYLOAD='{}' case "$HOOK" in SessionStart) TYPE=session_start MODEL=$(jget model) rm -f "$STATE.blocked" "$STATE.last" "$STATE.count" find "$STATE_DIR" -type f -mtime +7 -exec rm -f {} + 2>/dev/null ;; PostToolUse|SubagentStop) if [ -f "$STATE.blocked" ]; then # No permission_resolved hook exists; resumed activity proves it (§4). rm -f "$STATE.blocked" TYPE=permission_resolved else # Coalesce the heartbeat: at most one activity event per 30 s (§3). NOW=$(date +%s) LAST=$(cat "$STATE.last" 2>/dev/null || echo 0) COUNT=$(cat "$STATE.count" 2>/dev/null || echo 0) if [ $((NOW - LAST)) -lt 30 ]; then echo $((COUNT + 1)) >"$STATE.count" exit 0 fi echo "$NOW" >"$STATE.last" rm -f "$STATE.count" TYPE=activity PAYLOAD='{"count":'$((COUNT + 1))'}' fi ;; PostToolUseFailure) rm -f "$STATE.blocked" TYPE=tool_error PAYLOAD='{"tool":"'"$(jget tool_name | tr -d '"\\')"'"}' ;; UserPromptSubmit) # OPT-IN ONLY (§8): prompt text is default-excluded. When enabled, the # FIRST substantial prompt of a session becomes its summary via scrub(); # later prompts never overwrite it. First prompts state the task, # follow-ups are conversation ("yes", "try again") that would clobber it. [ "$(cfg prompt_summaries)" = "true" ] || exit 0 [ -f "$STATE.summary" ] && exit 0 SUMMARY=$(jget prompt | scrub) [ "${#SUMMARY}" -ge 8 ] || exit 0 # "hi" must not claim the slot touch "$STATE.summary" TYPE=activity PAYLOAD='{"summary":"'"$SUMMARY"'"}' ;; Notification) NT=$(jget notification_type) if [ -z "$NT" ]; then # Older harness versions: only the human-readable message exists. case "$(jget message)" in *permission*) NT=permission_prompt ;; *waiting*) NT=idle_prompt ;; esac fi case "$NT" in permission_prompt) touch "$STATE.blocked"; TYPE=permission_requested ;; idle_prompt) TYPE=notification ;; *) exit 0 ;; esac ;; Stop) rm -f "$STATE.blocked" TYPE=stop # OPT-IN ONLY (§8): model output is default-excluded. When enabled, the # agent's closing message — its own account of what it just did or needs # — replaces the summary, through the same scrub(). if [ "$(cfg reply_summaries)" = "true" ]; then SUMMARY=$(jget last_assistant_message | scrub) [ "${#SUMMARY}" -ge 8 ] && PAYLOAD='{"summary":"'"$SUMMARY"'"}' fi ;; SessionEnd) TYPE=session_end rm -f "$STATE.blocked" "$STATE.last" "$STATE.count" ;; *) exit 0 ;; esac # Labels. Sanitised so they can be interpolated into JSON verbatim. clean() { tr -d '"\\' ; } MACHINE=$(cfg machine); [ -n "$MACHINE" ] || MACHINE=$(hostname -s) TOPLEVEL=$(git -C "$PROJ_DIR" rev-parse --show-toplevel 2>/dev/null || true) BRANCH=$(git -C "$PROJ_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true) REPO=$(git -C "$PROJ_DIR" remote get-url origin 2>/dev/null | sed -e 's/\.git$//' -e 's#.*[:/]\([^/][^/]*/[^/][^/]*\)$#\1#' || true) WORKTREE=""; [ -n "$TOPLEVEL" ] && WORKTREE=$(basename "$TOPLEVEL") PROJECT=$(cfg project) [ -n "$PROJECT" ] || PROJECT=${REPO##*/} [ -n "$PROJECT" ] || PROJECT=$(basename "$PROJ_DIR") # Stable across restarts: resume keeps the harness session id (§3). sha() { if command -v shasum >/dev/null 2>&1; then shasum -a 256; else sha256sum; fi; } SESSION_KEY=$(printf '%s|%s|%s' "$MACHINE" "$PROJ_DIR" "$SESSION_ID" | sha | cut -c1-32) field() { [ -n "$2" ] && printf '"%s":"%s",' "$1" "$(printf '%s' "$2" | clean)"; } BODY="{\"v\":1,\ \"event_id\":\"$(uuidgen | tr 'A-Z' 'a-z')\",\ \"session_key\":\"$SESSION_KEY\",\ \"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\ $(field machine "$MACHINE")\ $(field project "$PROJECT")\ $(field repo "$REPO")\ $(field worktree "$WORKTREE")\ $(field branch "$BRANCH")\ $(field model "$MODEL")\ \"harness\":\"claude-code\",\"type\":\"$TYPE\",\"payload\":$PAYLOAD}" curl -sS -o /dev/null --max-time 3 --retry 1 -X POST "$URL/v1/events" \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d "$BODY" 2>/dev/null exit 0