#!/usr/bin/env bash set -euo pipefail # pre-tool-use.sh - PreToolUse hook for threshold enforcement # Purpose: Block file modification tools when diff threshold is exceeded # Source library functions SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/git-state.sh" source "$SCRIPT_DIR/../lib/state-manager.sh" source "$SCRIPT_DIR/../lib/threshold-calculator.sh" # Read hook input from stdin input=$(cat) session_id=$(echo "$input" | jq -r '.session_id') tool_name=$(echo "$input" | jq -r '.tool_name') hook_event_name=$(echo "$input" | jq -r '.hook_event_name') # Validate hook event (defensive check) if [[ "$hook_event_name" != "PreToolUse" ]]; then exit 0 fi # Check if tool is a modification tool (early return optimization) case "$tool_name" in Write | Edit) # This is a file modification tool - proceed with threshold check ;; *) # Not a file modification tool - allow it immediately exit 0 ;; esac # Load session state if ! session_state=$(read_session_state "$session_id" 2>/dev/null); then # No session state - fail open (allow tool) exit 0 fi # Extract state baseline_tree=$(echo "$session_state" | jq -r '.baseline_tree') threshold_limit=$(echo "$session_state" | jq -r '.threshold_limit') stop_triggered=$(echo "$session_state" | jq -r '.stop_triggered // false') previous_tree=$(echo "$session_state" | jq -r '.previous_tree // .baseline_tree') accumulated_score=$(echo "$session_state" | jq -r '.accumulated_score // 0') paused=$(echo "$session_state" | jq -r '.paused // false') # Capture current working tree (need this for both paths) if ! current_tree=$(capture_tree 2>/dev/null); then # Failed to capture tree - fail open exit 0 fi # Check for branch switch BEFORE threshold calculation # This catches branch changes that Stop hook missed (e.g., switch and immediately edit) baseline_branch=$(echo "$session_state" | jq -r '.baseline_branch // ""') current_branch=$(get_current_branch) if [[ -n "$baseline_branch" ]] && [[ -n "$current_branch" ]] && [[ "$baseline_branch" != "$current_branch" ]]; then # Branch switched - reset baseline and allow this tool (fresh start) reset_baseline_stale "$session_id" "$current_tree" "$current_branch" # Output notification and allow tool jq -n \ --arg baseline_branch "$baseline_branch" \ --arg current_branch "$current_branch" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" }, systemMessage: "↪ Bumper lanes: Branch changed (\($baseline_branch) → \($current_branch)) — baseline auto-reset." }' exit 0 fi # Helper: Output fuel gauge JSON if approaching threshold output_fuel_gauge() { local score="$1" local limit="$2" local message message=$(get_fuel_gauge_message "$score" "$limit") if [[ -n "$message" ]]; then jq -n --arg msg "$message" '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" }, systemMessage: $msg }' fi } # Check if enforcement is paused # When paused, we still track changes but don't enforce thresholds if [[ "$paused" == "true" ]]; then threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score") new_accumulated_score=$(echo "$threshold_data" | jq -r '.accumulated_score') update_incremental_state "$session_id" "$current_tree" "$new_accumulated_score" jq -n \ --argjson score "$new_accumulated_score" \ --argjson limit "$threshold_limit" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" }, systemMessage: "Bumper lanes paused (\($score)/\($limit) pts)" }' exit 0 fi # Check if Stop hook has been triggered # PreToolUse only blocks AFTER Stop hook has fired once if [[ "$stop_triggered" != "true" ]]; then # Stop hasn't triggered yet - allow tool to proceed # BUT update incremental state for next check threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score") new_accumulated_score=$(echo "$threshold_data" | jq -r '.accumulated_score') update_incremental_state "$session_id" "$current_tree" "$new_accumulated_score" # Output fuel gauge if approaching threshold output_fuel_gauge "$new_accumulated_score" "$threshold_limit" exit 0 fi # Stop has triggered - now enforce blocking on Write/Edit tools # Use incremental calculation threshold_data=$(calculate_incremental_threshold "$previous_tree" "$current_tree" "$accumulated_score") weighted_score=$(echo "$threshold_data" | jq -r '.accumulated_score') # Check threshold if [[ $weighted_score -le $threshold_limit ]]; then # Under threshold - allow tool and update state update_incremental_state "$session_id" "$current_tree" "$weighted_score" # Output fuel gauge if approaching threshold output_fuel_gauge "$weighted_score" "$threshold_limit" exit 0 fi # Over threshold - deny tool call # Format breakdown for user message # Note: threshold_data contains both weighted_score (delta) and accumulated_score (total) # For user display, we need to show the accumulated total, not just this turn's delta threshold_data_for_display=$(echo "$threshold_data" | jq '.weighted_score = .accumulated_score') breakdown=$(format_threshold_breakdown "$threshold_data_for_display" "$threshold_limit") # Build denial reason reason=" 🚫 Bumper lanes: Diff threshold exceeded $breakdown Cannot modify files while over threshold. Review your changes and run /bumper-reset to continue. " # Output denial decision using modern JSON format jq -n \ --arg reason "$reason" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason } }' exit 0