Files
gh-kylesnowschwartz-claude-…/hooks/entrypoints/pre-tool-use.sh
2025-11-30 08:36:18 +08:00

169 lines
5.6 KiB
Bash
Executable File

#!/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