Initial commit
This commit is contained in:
168
hooks/entrypoints/pre-tool-use.sh
Executable file
168
hooks/entrypoints/pre-tool-use.sh
Executable file
@@ -0,0 +1,168 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user