loop-bash-validator.sh 23.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
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
#!/usr/bin/env bash
#
# PreToolUse Hook: Validate Bash commands for RLCR loop
#
# Blocks attempts to bypass Write/Edit hooks using shell commands:
# - cat/echo/printf > file.md (redirection)
# - tee file.md
# - sed -i file.md (in-place edit)
# - goal-tracker.md modifications via Bash
#

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" != "Bash" ]]; then
    exit 0
fi

# Require command for Bash tool
if ! require_tool_input_field "$HOOK_INPUT" "command"; then
    exit 1
fi

COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // ""')
COMMAND_LOWER=$(to_lower "$COMMAND")

# ========================================
# Find Active Loops (needed for multiple checks)
# ========================================

PROJECT_ROOT="$(resolve_project_root)" || exit 0

# Extract session_id from hook input for session-aware loop filtering
HOOK_SESSION_ID=$(extract_session_id "$HOOK_INPUT")

# Check for active RLCR loop (filtered by session_id)
LOOP_BASE_DIR="$PROJECT_ROOT/.humanize/rlcr"
ACTIVE_LOOP_DIR=$(find_active_loop "$LOOP_BASE_DIR" "$HOOK_SESSION_ID")

# ========================================
# Methodology Analysis Phase Bash Restriction
# ========================================
# During methodology analysis, block file-modifying bash commands.
# Only read-only operations and cancel-rlcr-loop.sh are allowed.
# This prevents source code modifications after Codex has signed off.
#
# Accepted limitations:
# - Read-only bash commands (cat, grep, find, etc.) are NOT blocked. Blocking
#   them would break basic Claude operations. The analysis prompt directs Claude
#   to derive user-facing content only from methodology-analysis-report.md.
# - Spawned agents (different session_id) are not restricted by hooks; their
#   sanitization is enforced by the analysis prompt. This is an inherent
#   limitation of the hook architecture which cannot distinguish spawned agents
#   from unrelated sessions.
#
# 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.
_MA_BASH_DIR="$ACTIVE_LOOP_DIR"

if [[ -n "$_MA_BASH_DIR" ]] && [[ -f "$_MA_BASH_DIR/methodology-analysis-state.md" ]]; then
    # Allow cancel-rlcr-loop.sh only as the leading command (not as an argument
    # to another command like cp/mv). The optional path prefix must be a single
    # token with no embedded whitespace, otherwise commands like
    # `bash cancel-rlcr-loop.sh` or `tee cancel-rlcr-loop.sh` would match.
    # The script name must be followed by whitespace or end-of-line so trailing
    # tokens cannot hide additional arguments.
    #
    # Also reject any shell metacharacter that can inject or redirect work
    # after the cancel invocation: pipes/sequence/background operators,
    # command substitution ($(...) or backticks), redirection (<, >), and
    # multi-line payloads. The earlier narrower check only rejected ; | &,
    # letting payloads like `cancel-rlcr-loop.sh $(touch /tmp/pwn)` or a
    # newline-delimited second command slip past this early exit and reach
    # arbitrary file modifications before the downstream blockers run.
    _ma_has_shell_meta=false
    case "$COMMAND_LOWER" in
        *';'*|*'|'*|*'&'*|*'`'*|*'>'*|*'<'*|*'$('*|*$'\n'*)
            _ma_has_shell_meta=true
            ;;
    esac
    if [[ "$_ma_has_shell_meta" != "true" ]] && \
       echo "$COMMAND_LOWER" | grep -qE '^[[:space:]]*"?([^[:space:]"]+/)?cancel-rlcr-loop\.sh"?([[:space:]]|$)'; then
        exit 0
    fi
    # Block git commands that modify the working tree
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])git[[:space:]]+(commit|add|reset|checkout|merge|rebase|cherry-pick|am|apply|stash|push|restore|clean|rm|mv|switch|pull|clone|submodule|worktree)'; then
        echo "# Bash Blocked During Methodology Analysis

Git write commands are not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block file manipulation commands (touch, mv, cp, rm, mkdir, ln, patch, etc.)
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(tee|install|touch|mv|cp|rm|dd|truncate|chmod|chown|mkdir|rmdir|ln|mktemp|patch)[[:space:]]'; then
        echo "# Bash Blocked During Methodology Analysis

File modification commands are not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block in-place file editing tools
    if echo "$COMMAND_LOWER" | grep -qE 'sed[[:space:]]+-i|awk[[:space:]]+-i[[:space:]]+inplace|perl[[:space:]]+-[^[:space:]]*i'; then
        echo "# Bash Blocked During Methodology Analysis

In-place file editing is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block common interpreters that could write files (defense-in-depth)
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(python[23]?|ruby|node|perl|php)[[:space:]]'; then
        echo "# Bash Blocked During Methodology Analysis

Running interpreters is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block shell script entry points (bash script.sh, sh script.sh, source, .)
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(/usr/bin/env[[:space:]]+)?(bash|sh|zsh|/bin/bash|/bin/sh|/bin/zsh)[[:space:]]'; then
        echo "# Bash Blocked During Methodology Analysis

Running shell scripts is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block build tools that execute arbitrary commands
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(make|cmake|ninja|gradle|mvn|ant|cargo|go[[:space:]]+run|go[[:space:]]+generate|npm[[:space:]]+run|yarn[[:space:]]+run|npx|pnpm)[[:space:]]'; then
        echo "# Bash Blocked During Methodology Analysis

Build tools are not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block source/dot commands (source script.sh, . script.sh)
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])(source|\.)[ 	]+[^[:space:]]'; then
        echo "# Bash Blocked During Methodology Analysis

Sourcing scripts is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block direct script execution (./script.sh, ../script.sh, /path/to/script)
    if echo "$COMMAND_LOWER" | grep -qE '(^|[[:space:];|&])\.{0,2}/[^[:space:]>|&;]*\.(sh|bash|py|rb|pl|js)'; then
        echo "# Bash Blocked During Methodology Analysis

Direct script execution is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
    # Block output redirection to files (catches cat > file, echo > file, etc.)
    # Strip safe redirections (/dev/ paths, fd duplication) then check for remaining >
    _ma_stripped=$(echo "$COMMAND_LOWER" | sed 's|[0-9]*>[>]*[[:space:]]*/dev/[^[:space:]]*||g; s|[0-9]*>&[0-9]*||g')
    if echo "$_ma_stripped" | grep -qE '[>]'; then
        echo "# Bash Blocked During Methodology Analysis

File redirection is not allowed during the methodology analysis phase." >&2
        exit 2
    fi
fi

# If no active RLCR loop, allow all commands
if [[ -z "$ACTIVE_LOOP_DIR" ]]; then
    exit 0
fi

# ========================================
# Block Direct Execution of Hook Scripts
# ========================================
# Prevents Claude from manually running stop hook or stop gate scripts.
# These scripts should only be invoked by the hooks system, not via Bash.

BLOCKED_HOOK_SCRIPTS="(loop-codex-stop-hook\.sh|rlcr-stop-gate\.sh)"
HOOK_ASSIGNMENT_PREFIX="[[:alpha:]_][[:alnum:]_]*=[^[:space:];&|]+"
HOOK_COMMAND_PREFIX="command([[:space:]]+(-[^[:space:];&|]+|--))*"
HOOK_ENV_PREFIX="env([[:space:]]+(-[^[:space:];&|]+|--|${HOOK_ASSIGNMENT_PREFIX}))*"
HOOK_UTILITY_ARG="[^[:space:];&|]+"
HOOK_TIMEOUT_OPTION="(-[^[:space:];&|]+([[:space:]]+${HOOK_UTILITY_ARG})?|--([^[:space:];&|]+(=${HOOK_UTILITY_ARG}|[[:space:]]+${HOOK_UTILITY_ARG})?)?)"
HOOK_NICE_OPTION="(-n([[:space:]]+${HOOK_UTILITY_ARG})?|--adjustment(=${HOOK_UTILITY_ARG}|[[:space:]]+${HOOK_UTILITY_ARG})|-[^[:space:];&|]+|--[^[:space:];&|]+)"
HOOK_TRACE_OPTION="(-[^[:space:];&|]+([[:space:]]+${HOOK_UTILITY_ARG})?|--([^[:space:];&|]+(=${HOOK_UTILITY_ARG}|[[:space:]]+${HOOK_UTILITY_ARG})?)?)"
HOOK_TIMEOUT_PREFIX="timeout([[:space:]]+(${HOOK_TIMEOUT_OPTION}))*([[:space:]]+--)?[[:space:]]+${HOOK_UTILITY_ARG}"
HOOK_NICE_PREFIX="nice([[:space:]]+(${HOOK_NICE_OPTION}))*([[:space:]]+--)?"
HOOK_NOHUP_PREFIX="nohup"
HOOK_TRACE_PREFIX="(strace|ltrace)([[:space:]]+(${HOOK_TRACE_OPTION}))*([[:space:]]+--)?"
HOOK_UTILITY_PREFIX="(${HOOK_TIMEOUT_PREFIX}|${HOOK_NICE_PREFIX}|${HOOK_NOHUP_PREFIX}|${HOOK_TRACE_PREFIX})"
HOOK_WRAPPER_PREFIX_PATTERN="((${HOOK_ASSIGNMENT_PREFIX}|${HOOK_COMMAND_PREFIX}|${HOOK_ENV_PREFIX}|${HOOK_UTILITY_PREFIX})[[:space:]]+)*"
HOOK_LAUNCH_PATTERN="(([^[:space:]]*/)?|(bash|sh|zsh|source|\.)[[:space:]].*)$BLOCKED_HOOK_SCRIPTS"
if echo "$COMMAND_LOWER" | grep -qE "(^|[;&|])[[:space:]]*${HOOK_WRAPPER_PREFIX_PATTERN}${HOOK_LAUNCH_PATTERN}"; then
    stop_hook_direct_execution_blocked_message >&2
    exit 2
fi

# ========================================
# RLCR Loop Specific Checks
# ========================================
# The following checks only apply when an RLCR loop is active

if [[ -n "$ACTIVE_LOOP_DIR" ]]; then
    # Detect if we're in Finalize Phase (finalize-state.md exists)
    STATE_FILE=$(resolve_active_state_file "$ACTIVE_LOOP_DIR")

    # Parse state file using strict validation (fail closed on malformed state)
    if ! parse_state_file_strict "$STATE_FILE" 2>/dev/null; then
        echo "Error: Malformed state file, blocking operation for safety" >&2
        exit 1
    fi
    CURRENT_ROUND="$STATE_CURRENT_ROUND"

    # ========================================
    # Block Git Push When push_every_round is false
    # ========================================
    # Default behavior: commits stay local, no need to push to remote

    # Note: parse_state_file was called above, STATE_* vars are available
    PUSH_EVERY_ROUND="$STATE_PUSH_EVERY_ROUND"

    if [[ "$PUSH_EVERY_ROUND" != "true" ]]; then
        # Check if command is a git push command
        if [[ "$COMMAND_LOWER" =~ ^[[:space:]]*git[[:space:]]+push ]]; then
            FALLBACK="# Git Push Blocked

Commits should stay local during the RLCR loop.
Use --push-every-round flag when starting the loop if you need to push each round."
            load_and_render_safe "$TEMPLATE_DIR" "block/git-push.md" "$FALLBACK" >&2
            exit 2
        fi
    fi
fi

# ========================================
# Block Git Add Commands Targeting .humanize
# ========================================
# Prevents force-adding .humanize files to version control
# Note: .humanize is in .gitignore, but git add -f bypasses it

if git_adds_humanize "$COMMAND_LOWER"; then
    git_add_humanize_blocked_message >&2
    exit 2
fi

# ========================================
# RLCR State and File Protection
# ========================================
# These checks only apply when an RLCR loop is active

if [[ -n "$ACTIVE_LOOP_DIR" ]]; then

# ========================================
# Block State File Modifications (All Rounds)
# ========================================
# State file is managed by the loop system, not Claude
# This includes both state.md and finalize-state.md
# NOTE: Check finalize-state.md FIRST because state\.md pattern also matches finalize-state.md
# Exception: Allow mv to cancel-state.md when cancel signal file exists
#
# Note: We check TWO patterns for mv/cp:
# 1. command_modifies_file checks if DESTINATION contains state.md
# 2. Additional check below catches if SOURCE contains state.md (e.g., mv state.md /tmp/foo)

if command_modifies_file "$COMMAND_LOWER" "methodology-analysis-state\.md"; then
    # Check for cancel signal file - allow authorized cancel operation
    if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
        exit 0
    fi
    methodology_analysis_state_file_blocked_message >&2
    exit 2
fi

if command_modifies_file "$COMMAND_LOWER" "finalize-state\.md"; then
    # Check for cancel signal file - allow authorized cancel operation
    if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
        exit 0
    fi
    finalize_state_file_blocked_message >&2
    exit 2
fi

# Check 1: Destination contains state.md (covers writes, redirects, mv/cp TO state.md)
if command_modifies_file "$COMMAND_LOWER" "state\.md"; then
    # Check for cancel signal file - allow authorized cancel operation
    if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
        exit 0
    fi
    state_file_blocked_message >&2
    exit 2
fi

# Check 2: Source of mv/cp contains state.md (covers mv/cp FROM state.md to any destination)
# This catches bypass attempts like: mv state.md /tmp/foo.txt
# Pattern handles:
# - Options like -f, -- before the source path
# - Leading whitespace and command prefixes with options (sudo -u root, env VAR=val, command --)
# - Quoted relative paths like: mv -- "state.md" /tmp/foo
# - Command chaining via ;, &&, ||, |, |&, & (each segment is checked independently)
# - Shell wrappers: sh -c, bash -c, /bin/sh -c, /bin/bash -c
# Requires state.md to be a proper filename (preceded by space, /, or quote)
# Note: sudo/command patterns match zero or more arguments (each: space + optional-minus + non-space chars)

# Split command on shell operators and check each segment
# This catches chained commands like: true; mv state.md /tmp/foo
MV_CP_SOURCE_PATTERN="^[[:space:]]*(sudo([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(env[[:space:]]+[^;&|]*[[:space:]]+)?(command([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(mv|cp)[[:space:]].*[[:space:]/\"']state\.md"
MV_CP_FINALIZE_SOURCE_PATTERN="^[[:space:]]*(sudo([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(env[[:space:]]+[^;&|]*[[:space:]]+)?(command([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(mv|cp)[[:space:]].*[[:space:]/\"']finalize-state\.md"
MV_CP_METHODOLOGY_SOURCE_PATTERN="^[[:space:]]*(sudo([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(env[[:space:]]+[^;&|]*[[:space:]]+)?(command([[:space:]]+-?[^[:space:];&|]+)*[[:space:]]+)?(mv|cp)[[:space:]].*[[:space:]/\"']methodology-analysis-state\.md"

# Replace shell operators with newlines, then check each segment
# Order matters: |& before |, && before single &
# For &: protect redirections (&>>, &>, >&, N>&M) with placeholders, then split on remaining &
# Placeholders use control chars unlikely to appear in commands
# Note: &>> must be replaced before &> to avoid leaving a stray >
COMMAND_SEGMENTS=$(echo "$COMMAND_LOWER" | sed '
    s/|&/\n/g
    s/&&/\n/g
    s/&>>/\x03/g
    s/&>/\x01/g
    s/[0-9]*>&[0-9]*/\x02/g
    s/>&/\x02/g
    s/&/\n/g
    s/||/\n/g
    s/|/\n/g
    s/;/\n/g
')
while IFS= read -r SEGMENT; do
    # Skip empty segments
    [[ -z "$SEGMENT" ]] && continue

    # Strip leading redirections before pattern matching
    # This handles cases like: 2>/tmp/x mv, 2> /tmp/x mv, >/tmp/x mv, 2>&1 mv, &>/tmp/x mv
    # Also handles append redirections: >> /tmp/x mv, 2>> /tmp/x mv, &>> /tmp/x mv
    # Also handles quoted targets: >> "/tmp/x y" mv, >> '/tmp/x y' mv
    # Also handles ANSI-C quoting: >> $'/tmp/x y' mv, >> $"/tmp/x y" mv
    # Also handles escaped-space targets: >> /tmp/x\ y mv
    # Must handle:
    # - \x01 (from &>) followed by optional space and target path (quoted, ANSI-C, escaped, or unquoted)
    # - \x02 (from >&, 2>&1) with NO target - just strip placeholder
    # - \x03 (from &>>) followed by optional space and target path (quoted, ANSI-C, escaped, or unquoted)
    # - Standard redirections [0-9]*[><]+ followed by optional space and target
    # Order: double-quoted, single-quoted, ANSI-C $'...', locale $"...", escaped-unquoted, plain-unquoted
    # Note: Escaped/ANSI-C patterns use sed -E for extended regex
    SEGMENT_CLEANED=$(echo "$SEGMENT" | sed '
        :again
        s/^[[:space:]]*\x01[[:space:]]*"[^"]*"[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x01[[:space:]]*'"'"'[^'"'"']*'"'"'[[:space:]]*//
        t again
    ' | sed -E "
        :again
        s/^[[:space:]]*\x01[[:space:]]*\\$'([^'\\\\]|\\\\.)*'[[:space:]]*//
        t again
    " | sed -E '
        :again
        s/^[[:space:]]*\x01[[:space:]]*\$"([^"\\]|\\.)*"[[:space:]]*//
        t again
    ' | sed -E '
        :again
        s/^[[:space:]]*\x01[[:space:]]*([^[:space:]\\]|\\.)+[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x01[[:space:]]*[^[:space:]]*[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x02[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x03[[:space:]]*"[^"]*"[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x03[[:space:]]*'"'"'[^'"'"']*'"'"'[[:space:]]*//
        t again
    ' | sed -E "
        :again
        s/^[[:space:]]*\x03[[:space:]]*\\$'([^'\\\\]|\\\\.)*'[[:space:]]*//
        t again
    " | sed -E '
        :again
        s/^[[:space:]]*\x03[[:space:]]*\$"([^"\\]|\\.)*"[[:space:]]*//
        t again
    ' | sed -E '
        :again
        s/^[[:space:]]*\x03[[:space:]]*([^[:space:]\\]|\\.)+[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*\x03[[:space:]]*[^[:space:]]*[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*[0-9]*[><][><]*[[:space:]]*"[^"]*"[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*[0-9]*[><][><]*[[:space:]]*'"'"'[^'"'"']*'"'"'[[:space:]]*//
        t again
    ' | sed -E "
        :again
        s/^[[:space:]]*[0-9]*[><]+[[:space:]]*\\$'([^'\\\\]|\\\\.)*'[[:space:]]*//
        t again
    " | sed -E '
        :again
        s/^[[:space:]]*[0-9]*[><]+[[:space:]]*\$"([^"\\]|\\.)*"[[:space:]]*//
        t again
    ' | sed -E '
        :again
        s/^[[:space:]]*[0-9]*[><]+[[:space:]]*([^[:space:]\\]|\\.)+[[:space:]]*//
        t again
    ' | sed '
        :again
        s/^[[:space:]]*[0-9]*[><][><]*[[:space:]]*[^[:space:]]*[[:space:]]*//
        t again
    ')

    # Check for methodology-analysis-state.md as SOURCE first (most specific pattern)
    if echo "$SEGMENT_CLEANED" | grep -qE "$MV_CP_METHODOLOGY_SOURCE_PATTERN"; then
        # Check for cancel signal file - allow authorized cancel operation
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        methodology_analysis_state_file_blocked_message >&2
        exit 2
    fi

    # Check for finalize-state.md as SOURCE (more specific than state.md)
    if echo "$SEGMENT_CLEANED" | grep -qE "$MV_CP_FINALIZE_SOURCE_PATTERN"; then
        # Check for cancel signal file - allow authorized cancel operation
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        finalize_state_file_blocked_message >&2
        exit 2
    fi

    if echo "$SEGMENT_CLEANED" | grep -qE "$MV_CP_SOURCE_PATTERN"; then
        # Check for cancel signal file - allow authorized cancel operation
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        state_file_blocked_message >&2
        exit 2
    fi
done <<< "$COMMAND_SEGMENTS"

# Check 3: Shell wrapper bypass (sh -c, bash -c)
# This catches bypass attempts like: sh -c 'mv state.md /tmp/foo'
# Pattern: look for sh/bash with -c flag and state.md or finalize-state.md in the payload
if echo "$COMMAND_LOWER" | grep -qE "(^|[[:space:]/])(sh|bash)[[:space:]]+-c[[:space:]]"; then
    # Shell wrapper detected - check if payload contains mv/cp methodology-analysis-state.md (most specific)
    if echo "$COMMAND_LOWER" | grep -qE "(mv|cp)[[:space:]].*methodology-analysis-state\.md"; then
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        methodology_analysis_state_file_blocked_message >&2
        exit 2
    fi
    # Shell wrapper detected - check if payload contains mv/cp finalize-state.md (check first, more specific)
    if echo "$COMMAND_LOWER" | grep -qE "(mv|cp)[[:space:]].*finalize-state\.md"; then
        # Check for cancel signal file - allow authorized cancel operation
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        finalize_state_file_blocked_message >&2
        exit 2
    fi
    # Shell wrapper detected - check if payload contains mv/cp state.md
    if echo "$COMMAND_LOWER" | grep -qE "(mv|cp)[[:space:]].*state\.md"; then
        # Check for cancel signal file - allow authorized cancel operation
        if is_cancel_authorized "$ACTIVE_LOOP_DIR" "$COMMAND_LOWER"; then
            exit 0
        fi
        state_file_blocked_message >&2
        exit 2
    fi
fi

# ========================================
# Block Plan Backup Modifications (All Rounds)
# ========================================
# Plan backup is read-only - protects plan integrity during loop
# Use command_modifies_file helper for consistent pattern matching

if command_modifies_file "$COMMAND_LOWER" "\.humanize/rlcr(/[^/]+)?/plan\.md"; 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

# ========================================
# Block Goal Tracker Modifications (All Rounds)
# ========================================
# Round 0: prompt to use Write/Edit
# Round > 0: prompt to put request in summary

if command_modifies_file "$COMMAND_LOWER" "goal-tracker\.md"; then
    GOAL_TRACKER_PATH="$ACTIVE_LOOP_DIR/goal-tracker.md"
    if [[ "$CURRENT_ROUND" -eq 0 ]]; then
        goal_tracker_bash_blocked_message "$GOAL_TRACKER_PATH" >&2
    else
        goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2
    fi
    exit 2
fi

# ========================================
# Block Prompt File Modifications (All Rounds)
# ========================================
# Prompt files are read-only - they contain instructions FROM Codex TO Claude

if command_modifies_file "$COMMAND_LOWER" "round-[0-9]+-prompt\.md"; then
    prompt_write_blocked_message >&2
    exit 2
fi

# ========================================
# Block Summary File Modifications (All Rounds)
# ========================================
# Summary files should be written using Write or Edit tools for proper validation

if command_modifies_file "$COMMAND_LOWER" "round-[0-9]+-summary\.md"; then
    CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-summary.md"
    summary_bash_blocked_message "$CORRECT_PATH" >&2
    exit 2
fi

# ========================================
# Block Round Contract File Modifications (All Rounds)
# ========================================
# Round contracts should be written using Write or Edit tools so round scoping
# stays aligned with the current loop state.

if command_modifies_file "$COMMAND_LOWER" "round-[0-9]+-contract\.md"; then
    CORRECT_PATH="$ACTIVE_LOOP_DIR/round-${CURRENT_ROUND}-contract.md"
    FALLBACK="# Round Contract Bash Write Blocked

Do not use Bash commands to modify round contract files.
Use the Write or Edit tool instead: {{CORRECT_PATH}}"
    load_and_render_safe "$TEMPLATE_DIR" "block/round-contract-bash-write.md" "$FALLBACK" \
        "CORRECT_PATH=$CORRECT_PATH" >&2
    exit 2
fi

# ========================================
# Block Todos File Modifications (All Rounds)
# ========================================

if command_modifies_file "$COMMAND_LOWER" "round-[0-9]+-todos\.md"; then
    # Require full path to active loop dir to prevent same-basename bypass from different roots
    ACTIVE_LOOP_DIR_LOWER=$(to_lower "$ACTIVE_LOOP_DIR")
    ACTIVE_LOOP_DIR_ESCAPED=$(echo "$ACTIVE_LOOP_DIR_LOWER" | sed 's/[\\.*^$[(){}+?|]/\\&/g')
    if ! echo "$COMMAND_LOWER" | grep -qE "${ACTIVE_LOOP_DIR_ESCAPED}/round-[12]-todos\.md"; then
        todos_blocked_message "Bash" >&2
        exit 2
    fi
fi

fi  # End of RLCR-specific checks

exit 0