Browse Source

feat(hooks): mid-session peer-writer guard (PreToolUse) + touched-files ledger

The SessionStart guard only catches a peer present at boot; collisions happen mid-session (as this very session proved). This closes that gap.

- pre-write-peer-guard.sh (PreToolUse Edit|Write): before writing a file, warn if it was modified <120s ago and is NOT in this session's edit ledger — the signature of a live peer session sharing the checkout. Advisory (additionalContext) by default; GUARD_BLOCK=1 denies the write.

- session-touched-ledger.sh (PostToolUse Edit|Write): records files this session wrote so the guard tells my own edits from a peer's.

Wiring is hand-applied in ~/.claude/settings.json (PreToolUse/PostToolUse, matcher Edit|Write) — the global settings file is not sourced from this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 2 weeks ago
parent
commit
4834110692
2 changed files with 78 additions and 0 deletions
  1. 54 0
      hooks/pre-write-peer-guard.sh
  2. 24 0
      hooks/session-touched-ledger.sh

+ 54 - 0
hooks/pre-write-peer-guard.sh

@@ -0,0 +1,54 @@
+#!/bin/bash
+# hooks/pre-write-peer-guard.sh
+# PreToolUse(Edit|Write) — mid-session peer-writer guard (the real fix: collisions happen *during* a
+# session, not just at boot). Before I write a file, warn if that file was JUST modified by something
+# that isn't me this session — the signature of a live peer Claude session sharing this checkout.
+#
+# Fires only when ALL hold for the target file:
+#   • it exists and is inside a git repo
+#   • git sees it as modified/untracked (dirty)
+#   • it was written in the last $THRESHOLD seconds (fresh — stale WIP is ignored)
+#   • it is NOT in this session's touched-ledger (companion hooks/session-touched-ledger.sh)
+#
+# Advisory by default (exit 0 + additionalContext → fed to the model, does NOT block the write).
+# Set GUARD_BLOCK=1 to DENY the write instead. Never crashes the tool call. See worktree-boundaries.md.
+
+set -uo pipefail
+
+THRESHOLD=120   # seconds — "freshly modified" window
+
+INPUT=$(cat 2>/dev/null) || exit 0
+command -v jq >/dev/null 2>&1 || exit 0
+SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
+FP=$(printf '%s'  "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
+[ -n "$FP" ] || exit 0
+[ -f "$FP" ] || exit 0   # new file → nothing to collide with yet
+
+DIR=$(dirname "$FP")
+git -C "$DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0   # not a repo → skip
+[ -n "$(git -C "$DIR" status --porcelain -- "$FP" 2>/dev/null)" ] || exit 0   # clean → no peer edit
+
+NOW=$(date +%s)
+M=$(stat -c %Y "$FP" 2>/dev/null || stat -f %m "$FP" 2>/dev/null)
+[ -n "${M:-}" ] || exit 0
+AGE=$(( NOW - M ))
+[ "$AGE" -lt "$THRESHOLD" ] || exit 0   # stale dirty (likely my own old WIP) → don't nag
+
+# Did I touch this file this session? Then it's mine → allow silently.
+LEDGER="$HOME/.claude/.session-touched/${SID}.list"
+if [ -n "$SID" ] && [ -f "$LEDGER" ] && grep -Fqx "$FP" "$LEDGER" 2>/dev/null; then
+  exit 0
+fi
+
+# Dirty + fresh + not mine → a peer session likely just edited this file.
+MSG="PEER-WRITE ADVISORY: ${FP} was modified ${AGE}s ago and is not in your edit history this session"
+MSG="${MSG} — another Claude session may be editing this same checkout. RE-READ the file before writing"
+MSG="${MSG} (your old_string may be stale and you could clobber its work). If a peer is live, move your"
+MSG="${MSG} work to a separate worktree (git worktree add ../<dir> -b <branch>). See rules/worktree-boundaries.md."
+
+if [ "${GUARD_BLOCK:-0}" = "1" ]; then
+  jq -n --arg r "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
+else
+  jq -n --arg c "$MSG" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}'
+fi
+exit 0

+ 24 - 0
hooks/session-touched-ledger.sh

@@ -0,0 +1,24 @@
+#!/bin/bash
+# hooks/session-touched-ledger.sh
+# PostToolUse(Edit|Write) — record the files THIS session has written, so the pre-write peer-guard
+# (hooks/pre-write-peer-guard.sh) can tell my own edits apart from a peer session's. Silent, never
+# blocks, exit 0 always. Companion: pre-write-peer-guard.sh. See rules/worktree-boundaries.md.
+#
+# Ledger: $HOME/.claude/.session-touched/<session_id>.list (one absolute file path per line).
+# Pruned opportunistically (files >2 days old) on the first write of each new session.
+
+set -uo pipefail
+
+INPUT=$(cat 2>/dev/null) || exit 0
+command -v jq >/dev/null 2>&1 || exit 0
+SID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty' 2>/dev/null)
+FP=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
+[ -n "$SID" ] && [ -n "$FP" ] || exit 0
+
+DIR="$HOME/.claude/.session-touched"
+LEDGER="$DIR/$SID.list"
+mkdir -p "$DIR" 2>/dev/null || exit 0
+# First write of this session → prune stale ledgers from old sessions.
+[ -f "$LEDGER" ] || find "$DIR" -type f -mtime +2 -delete 2>/dev/null || true
+printf '%s\n' "$FP" >> "$LEDGER" 2>/dev/null || true
+exit 0