loop-write-validator.sh 13.9 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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
#!/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 <loop>/<CLAUDE_FILENAME> 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