loop-edit-validator.sh 10.2 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
#!/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