#!/usr/bin/env bash # # PreToolUse Hook: Validate Write paths for RLCR loop # # Blocks Claude from writing to: # - Todos files (should use native Task tools instead) # - Prompt files (read-only, generated by Codex) # - Wrong round number summary files # - Wrong round number contract files # - Summary files outside .humanize/rlcr/ # - Goal tracker writes 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) # Validate JSON input structure if ! validate_hook_input "$HOOK_INPUT"; then exit 1 fi # Check for deeply nested JSON (potential DoS) if is_deeply_nested "$HOOK_INPUT" 30; then exit 1 fi TOOL_NAME="$VALIDATED_TOOL_NAME" if [[ "$TOOL_NAME" != "Write" ]]; then exit 0 fi # Require file_path for Write tool if ! require_tool_input_field "$HOOK_INPUT" "file_path"; then exit 1 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 "Write" >&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 Write Restriction # ======================================== # During methodology analysis, only methodology artifacts can be written. # This prevents source code modifications after Codex has signed off. # This check MUST come before the file type early exits 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 so prefix guards cannot be bypassed. # Reject paths with ".." segments to prevent traversal bypasses # when we cannot canonicalize (fail closed). if [[ -z "$_ma_real_path" ]]; then if [[ "$FILE_PATH" == *".."* ]]; then echo "# Write 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 # writes to arbitrary files during methodology-analysis mode. if [[ -L "$FILE_PATH" ]]; then echo "# Write 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 "# Write Blocked During Methodology Analysis During the methodology analysis phase, only methodology artifacts can be written. Allowed: methodology-analysis-report.md, methodology-analysis-done.md" >&2 exit 2 fi # ======================================== # Determine File Types # ======================================== IS_SUMMARY_FILE=$(is_round_file_type "$FILE_PATH_LOWER" "summary" && echo "true" || echo "false") IS_CONTRACT_FILE=$(is_round_file_type "$FILE_PATH_LOWER" "contract" && echo "true" || echo "false") IS_FINALIZE_SUMMARY=$(is_finalize_summary_path "$FILE_PATH_LOWER" && echo "true" || echo "false") IN_HUMANIZE_LOOP_DIR=$(is_in_humanize_loop_dir "$FILE_PATH" && echo "true" || echo "false") # If not a summary file, not a contract file, not a finalize summary, and not in .humanize/rlcr, allow normally if [[ "$IS_SUMMARY_FILE" == "false" ]] && [[ "$IS_CONTRACT_FILE" == "false" ]] && [[ "$IS_FINALIZE_SUMMARY" == "false" ]] && [[ "$IN_HUMANIZE_LOOP_DIR" == "false" ]]; then exit 0 fi # For state.md, finalize-state.md, methodology-analysis-state.md, goal-tracker.md, and plan.md in .humanize/rlcr, we need further validation # For other files in .humanize/rlcr that aren't summaries/contracts, allow them FILENAME=$(basename "$FILE_PATH") IS_PLAN_BACKUP=$([[ "$FILENAME" == "plan.md" ]] && echo "true" || echo "false") if [[ "$IN_HUMANIZE_LOOP_DIR" == "true" ]] && [[ "$IS_SUMMARY_FILE" == "false" ]] && [[ "$IS_CONTRACT_FILE" == "false" ]] && [[ "$IS_FINALIZE_SUMMARY" == "false" ]]; then if ! is_state_file_path "$FILE_PATH_LOWER" && ! is_finalize_state_file_path "$FILE_PATH_LOWER" && ! is_methodology_analysis_state_file_path "$FILE_PATH_LOWER" && ! is_goal_tracker_path "$FILE_PATH_LOWER" && [[ "$IS_PLAN_BACKUP" != "true" ]]; then exit 0 fi fi # ======================================== # Find Active Loop and Current Round # ======================================== # Re-initialize if not set by earlier todos check 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 Writes (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 # ======================================== # Allow Finalize Summary File # ======================================== # In Finalize Phase, allow writes to finalize-summary.md # This must be checked BEFORE the "summary files outside .humanize/rlcr" check if [[ "$IS_FINALIZE_SUMMARY" == "true" ]] && [[ "$IN_HUMANIZE_LOOP_DIR" == "true" ]]; then # Verify it's in the active loop directory if [[ "$FILE_PATH" == "$ACTIVE_LOOP_DIR/finalize-summary.md" ]]; then exit 0 fi fi # There is no active round contract once the loop has entered Finalize Phase. if [[ "$IS_FINALIZE_PHASE" == "true" ]] && [[ "$IS_CONTRACT_FILE" == "true" ]]; then finalize_contract_blocked_message "write to" >&2 exit 2 fi # ======================================== # Block Plan Backup Writes # ======================================== if [[ "$IS_PLAN_BACKUP" == "true" ]]; then if [[ "$FILE_PATH" == *"/.humanize/rlcr/"* ]]; then FALLBACK="Writing to 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 Writes # ======================================== 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 ! require_tool_input_field "$HOOK_INPUT" "content"; then exit 1 fi UPDATED_CONTENT=$(echo "$HOOK_INPUT" | jq -r '.tool_input.content // ""') 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 # ======================================== # Block Summary/Contract Files Outside .humanize/rlcr # ======================================== if [[ "$IS_SUMMARY_FILE" == "true" || "$IS_CONTRACT_FILE" == "true" ]] && [[ "$IN_HUMANIZE_LOOP_DIR" == "false" ]]; then if [[ "$IS_CONTRACT_FILE" == "true" ]]; then CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-contract.md" FALLBACK="# Wrong Round Contract Location Write the round contract to the correct path: {{CORRECT_PATH}}" load_and_render_safe "$TEMPLATE_DIR" "block/wrong-contract-location.md" "$FALLBACK" \ "CORRECT_PATH=$CORRECT_PATH" >&2 else CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-summary.md" FALLBACK="# Wrong Summary Location Write summary to the correct path: {{CORRECT_PATH}}" load_and_render_safe "$TEMPLATE_DIR" "block/wrong-summary-location.md" "$FALLBACK" \ "CORRECT_PATH=$CORRECT_PATH" >&2 fi exit 2 fi # ======================================== # Extract Path Components (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 [[ -z "$CLAUDE_FILENAME" ]]; then exit 0 fi # ======================================== # Validate Round Number (for summary/contract files) # ======================================== if [[ "$IS_SUMMARY_FILE" == "true" || "$IS_CONTRACT_FILE" == "true" ]]; then CLAUDE_ROUND=$(extract_round_number "$CLAUDE_FILENAME") FILE_TYPE=$([[ "$IS_CONTRACT_FILE" == "true" ]] && 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}}**. Write to: {{CORRECT_PATH}}" load_and_render_safe "$TEMPLATE_DIR" "block/wrong-round-number.md" "$FALLBACK" \ "ACTION=write to" \ "CLAUDE_ROUND=$CLAUDE_ROUND" \ "FILE_TYPE=$FILE_TYPE" \ "CURRENT_ROUND=$CURRENT_ROUND" \ "CORRECT_PATH=$CORRECT_PATH" >&2 exit 2 fi fi # ======================================== # Validate Directory Path # ======================================== CORRECT_PATH="$ACTIVE_LOOP_DIR/$CLAUDE_FILENAME" # Compare prefix-canonical forms so the check is not fooled by equivalent # paths expressed in different ancestor forms (e.g. /var/... vs /private/var/... # on macOS) -- without dereferencing the leaf. Using full realpath here # would let a planted symlink at / pointing outside # the loop dir approve a write through the link, escalating Claude's write # reach beyond the loop dir. canonicalize_path_prefix resolves the parent # directory only; the basename is compared verbatim. _WRITE_FILE_REAL=$(canonicalize_path_prefix "$FILE_PATH") _WRITE_CORRECT_REAL=$(canonicalize_path_prefix "$CORRECT_PATH") if [[ "${_WRITE_FILE_REAL:-$FILE_PATH}" != "${_WRITE_CORRECT_REAL:-$CORRECT_PATH}" ]]; then FALLBACK="# Wrong Directory Path You tried to {{ACTION}} {{FILE_PATH}} but the correct path is {{CORRECT_PATH}}" load_and_render_safe "$TEMPLATE_DIR" "block/wrong-directory-path.md" "$FALLBACK" \ "ACTION=write to" \ "FILE_PATH=$FILE_PATH" \ "CORRECT_PATH=$CORRECT_PATH" >&2 exit 2 fi exit 0