#!/usr/bin/env bash # # PreToolUse Hook: Validate Edit paths for RLCR loop # # Blocks Claude from editing: # - Todos files (should use native Task tools instead) # - Prompt files (read-only, generated by Codex) # - State files (managed by hooks, not Claude) # - Wrong round number contract files # - Goal tracker edits outside the active loop or that alter the immutable section # set -euo pipefail # Load shared functions SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" source "$SCRIPT_DIR/lib/loop-common.sh" # ======================================== # Parse Hook Input # ======================================== HOOK_INPUT=$(cat) TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // ""') if [[ "$TOOL_NAME" != "Edit" ]]; then exit 0 fi FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // ""') FILE_PATH_LOWER=$(to_lower "$FILE_PATH") # Extract session_id from hook input for session-aware loop filtering HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT") # ======================================== # Block Todos and Prompt Files # ======================================== if is_round_file_type "$FILE_PATH_LOWER" "todos"; then PROJECT_ROOT="$(resolve_project_root)" || exit 0 LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr" LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID") if [[ -z "$LOOP_DIR" ]] || ! is_allowlisted_file "$FILE_PATH" "$LOOP_DIR"; then todos_blocked_message "Edit" >&2 exit 2 fi fi if is_round_file_type "$FILE_PATH_LOWER" "prompt"; then prompt_write_blocked_message >&2 exit 2 fi # ======================================== # Methodology Analysis Phase Edit Restriction # ======================================== # During methodology analysis, only methodology artifacts can be edited. # This prevents source code modifications after Codex has signed off. # This check MUST come before the humanize loop dir early exit below. PROJECT_ROOT="${PROJECT_ROOT:-$(resolve_project_root 2>/dev/null || true)}" [[ -z "$PROJECT_ROOT" ]] && exit 0 LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}" # Use only the session-matched loop. Do NOT fall back to an unfiltered search, # as that would incorrectly restrict unrelated sessions opened in the same repo. # Limitation: Spawned agents (different session_id) are not restricted by hooks; # their sanitization is enforced by the analysis prompt. _MA_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")}" if [[ -n "$_MA_LOOP_DIR" ]] && [[ -f "$_MA_LOOP_DIR/methodology-analysis-state.md" ]]; then # If realpath fails (file doesn't exist yet on BSD/macOS), resolve parent dir _ma_real_path=$(realpath "$FILE_PATH" 2>/dev/null || echo "") if [[ -z "$_ma_real_path" ]]; then _ma_parent=$(realpath "$(dirname "$FILE_PATH")" 2>/dev/null || echo "") [[ -n "$_ma_parent" ]] && _ma_real_path="$_ma_parent/$(basename "$FILE_PATH")" fi _ma_real_loop=$(realpath "$_MA_LOOP_DIR" 2>/dev/null || echo "") # Fallback to raw paths when realpath is unavailable (older macOS/BSD) # Ensure paths are absolute and reject ".." to prevent traversal bypasses. if [[ -z "$_ma_real_path" ]]; then if [[ "$FILE_PATH" == *".."* ]]; then echo "# Edit Blocked During Methodology Analysis Path contains traversal segments that cannot be resolved without realpath." >&2 exit 2 fi # Fail closed if the leaf is a symlink we cannot resolve; the raw # path would satisfy the loop-dir prefix check while pointing at a # target outside the loop, letting the basename allowlist approve # edits to arbitrary files during methodology-analysis mode. if [[ -L "$FILE_PATH" ]]; then echo "# Edit Blocked During Methodology Analysis Path is a symlink that cannot be resolved without realpath." >&2 exit 2 fi if [[ "$FILE_PATH" == /* ]]; then _ma_real_path="$FILE_PATH" else _ma_real_path="$PROJECT_ROOT/$FILE_PATH" fi fi if [[ -z "$_ma_real_loop" ]]; then if [[ "$_MA_LOOP_DIR" == /* ]]; then _ma_real_loop="$_MA_LOOP_DIR" else _ma_real_loop="$PROJECT_ROOT/$_MA_LOOP_DIR" fi fi if [[ "$_ma_real_path" == "$_ma_real_loop/"* ]]; then _ma_basename=$(basename "$_ma_real_path") case "$_ma_basename" in methodology-analysis-report.md|methodology-analysis-done.md) exit 0 ;; esac fi echo "# Edit Blocked During Methodology Analysis During the methodology analysis phase, only methodology artifacts can be edited. Allowed: methodology-analysis-report.md, methodology-analysis-done.md" >&2 exit 2 fi # ======================================== # Check if File is in .humanize/rlcr # ======================================== if ! is_in_humanize_loop_dir "$FILE_PATH"; then exit 0 fi # ======================================== # Find Active Loop and Current Round # ======================================== PROJECT_ROOT="${PROJECT_ROOT:-$(resolve_project_root 2>/dev/null || true)}" [[ -z "$PROJECT_ROOT" ]] && exit 0 LOOP_BASE_DIR="${LOOP_BASE_DIR:-$PROJECT_ROOT/.humanize/rlcr}" ACTIVE_LOOP_DIR="${LOOP_DIR:-$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")}" if [[ -z "$ACTIVE_LOOP_DIR" ]]; then exit 0 fi # Detect if we're in Finalize Phase (finalize-state.md exists) STATE_FILE_TO_PARSE=$(resolve_active_state_file "$ACTIVE_LOOP_DIR") IS_FINALIZE_PHASE=false if [[ "$STATE_FILE_TO_PARSE" == *"/finalize-state.md" ]]; then IS_FINALIZE_PHASE=true fi # Parse state file using strict validation (fail closed on malformed state) if ! parse_state_file_strict "$STATE_FILE_TO_PARSE" 2>/dev/null; then echo "Error: Malformed state file, blocking operation for safety" >&2 exit 1 fi CURRENT_ROUND="$STATE_CURRENT_ROUND" # ======================================== # Block State File Edits (state.md, finalize-state.md, methodology-analysis-state.md) # ======================================== # NOTE: Check most specific patterns first because is_state_file_path matches any *state.md if is_methodology_analysis_state_file_path "$FILE_PATH_LOWER"; then methodology_analysis_state_file_blocked_message >&2 exit 2 fi if is_finalize_state_file_path "$FILE_PATH_LOWER"; then finalize_state_file_blocked_message >&2 exit 2 fi if is_state_file_path "$FILE_PATH_LOWER"; then state_file_blocked_message >&2 exit 2 fi if [[ "$IS_FINALIZE_PHASE" == "true" ]] && is_round_file_type "$FILE_PATH_LOWER" "contract"; then finalize_contract_blocked_message "edit" >&2 exit 2 fi # ======================================== # Block Plan Backup Edits # ======================================== FILENAME=$(basename "$FILE_PATH") if [[ "$FILENAME" == "plan.md" ]]; then if [[ "$FILE_PATH" == *"/.humanize/rlcr/"* ]]; then FALLBACK="Editing plan.md backup is not allowed during RLCR loop." REASON=$(load_and_render_safe "$TEMPLATE_DIR" "block/plan-backup-protected.md" "$FALLBACK") echo "$REASON" >&2 exit 2 fi fi # ======================================== # Validate Goal Tracker Edits # ======================================== if is_goal_tracker_path "$FILE_PATH_LOWER"; then GOAL_TRACKER_PATH="$ACTIVE_LOOP_DIR/goal-tracker.md" NORMALIZED_FILE_PATH=$(_normalize_path "$FILE_PATH") NORMALIZED_GOAL_TRACKER_PATH=$(_normalize_path "$GOAL_TRACKER_PATH") if [[ "$NORMALIZED_FILE_PATH" != "$NORMALIZED_GOAL_TRACKER_PATH" ]]; then goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2 exit 2 fi if [[ "$CURRENT_ROUND" -gt 0 ]]; then if ! echo "$HOOK_INPUT" | jq -e '.tool_input | has("old_string") and has("new_string")' >/dev/null 2>&1; then echo "Error: Missing required field: tool_input.old_string or tool_input.new_string" >&2 exit 1 fi OLD_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.old_string // ""') if [[ -z "$OLD_STRING" ]]; then echo "Error: Missing required field: tool_input.old_string" >&2 exit 1 fi NEW_STRING=$(echo "$HOOK_INPUT" | jq -r '.tool_input.new_string // ""') REPLACE_ALL=$(echo "$HOOK_INPUT" | jq -r '.tool_input.replace_all // false') if ! UPDATED_CONTENT=$(preview_edit_result "$GOAL_TRACKER_PATH" "$OLD_STRING" "$NEW_STRING" "$REPLACE_ALL" 2>/dev/null); then goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2 exit 2 fi if ! goal_tracker_mutable_update_allowed "$GOAL_TRACKER_PATH" "$UPDATED_CONTENT"; then goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2 exit 2 fi fi exit 0 fi # ======================================== # Validate Summary/Contract File Round Number # ======================================== if is_round_file_type "$FILE_PATH_LOWER" "summary" || is_round_file_type "$FILE_PATH_LOWER" "contract"; then # Extract filename from path (portable - works in bash and zsh) CLAUDE_FILENAME=$(echo "$FILE_PATH" | sed -n 's|.*\.humanize/rlcr/[^/]*/\(.*\)$|\1|p') if [[ -z "$CLAUDE_FILENAME" ]]; then CLAUDE_FILENAME=$(echo "$FILE_PATH" | sed -n 's|.*\.humanize/rlcr/\(.*\)$|\1|p') fi if [[ -n "$CLAUDE_FILENAME" ]]; then CLAUDE_ROUND=$(extract_round_number "$CLAUDE_FILENAME") FILE_TYPE=$([[ "$FILE_PATH_LOWER" == *"-contract.md" ]] && echo "contract" || echo "summary") if [[ -n "$CLAUDE_ROUND" ]] && [[ "$CLAUDE_ROUND" != "$CURRENT_ROUND" ]] && ! is_allowlisted_file "$FILE_PATH" "$ACTIVE_LOOP_DIR"; then CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-${FILE_TYPE}.md" FALLBACK="# Wrong Round Number You tried to {{ACTION}} round-{{CLAUDE_ROUND}}-{{FILE_TYPE}}.md but current round is **{{CURRENT_ROUND}}**. Edit: {{CORRECT_PATH}}" load_and_render_safe "$TEMPLATE_DIR" "block/wrong-round-number.md" "$FALLBACK" \ "ACTION=edit" \ "CLAUDE_ROUND=$CLAUDE_ROUND" \ "FILE_TYPE=$FILE_TYPE" \ "CURRENT_ROUND=$CURRENT_ROUND" \ "CORRECT_PATH=$CORRECT_PATH" >&2 exit 2 fi fi fi exit 0