From f19d863e510eb7bfb604a05d6b7864af13106a89 Mon Sep 17 00:00:00 2001 From: zengchang233 Date: Fri, 25 Sep 2026 18:46:21 +0800 Subject: [PATCH] Keep path identity when validators accept macOS path aliases aeaec5c made the validators tolerate /var vs /private/var on macOS, but two of those changes widen or break the checks: - The Bash validator strips a leading /private from the active loop dir and then matches it as a substring of the command. A command writing round-1-todos.md under another directory that merely ends with the loop path, such as /tmp/other/tmp/p/.humanize/rlcr/s, passes; on Linux, where /private/tmp and /tmp can be unrelated, a loop under /private/tmp/p also authorizes /tmp/p. Accept the other spelling only when canonicalize_path resolves both to the same existing directory, and require the path to start at a word boundary. Otherwise the exact loop path is required, as before. - The Write and Edit validators fall back to _normalize_path only when canonicalize_path fails, but it returns the raw path with status 0 when neither realpath nor python3 is available, so /./goal-tracker.md is rejected. Normalize the canonicalizer's result as well. Regression tests cover both spellings of the loop path, suffix and nested directories, a platform without the alias (realpath that does not resolve /private) and the tracker paths with realpath and python3 unavailable. Co-Authored-By: Claude Opus 5.5 --- hooks/loop-bash-validator.sh | 20 +++-- hooks/loop-edit-validator.sh | 8 +- hooks/loop-write-validator.sh | 8 +- tests/test-allowlist-validators.sh | 123 +++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 11 deletions(-) diff --git a/hooks/loop-bash-validator.sh b/hooks/loop-bash-validator.sh index aa455353..8c961fb9 100755 --- a/hooks/loop-bash-validator.sh +++ b/hooks/loop-bash-validator.sh @@ -560,11 +560,21 @@ fi 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. - # Strip leading /private prefix so canonical paths (/private/var) match user paths (/var) on macOS. - ACTIVE_LOOP_DIR_LOWER=$(to_lower "$ACTIVE_LOOP_DIR") - ACTIVE_LOOP_DIR_LOWER_NORM="${ACTIVE_LOOP_DIR_LOWER#/private}" - ACTIVE_LOOP_DIR_ESCAPED=$(echo "$ACTIVE_LOOP_DIR_LOWER_NORM" | sed 's/[\\.*^$[(){}+?|]/\\&/g') - if ! echo "$COMMAND_LOWER" | grep -qE "${ACTIVE_LOOP_DIR_ESCAPED}/round-[12]-todos\.md"; then + # macOS exposes /var, /tmp and /etc also as /private/var, /private/tmp and /private/etc. + # Accept the spelling with and without /private only when the filesystem confirms that + # both resolve to the same existing directory; elsewhere they can be unrelated. The path + # must start at a word boundary, so a directory that merely ends with it does not match. + LOOP_DIR_SHORT="${ACTIVE_LOOP_DIR#/private}" + LOOP_DIR_FOR_MATCH="$ACTIVE_LOOP_DIR" + PRIVATE_ALIAS_PREFIX="" + SHORT_REAL=$(canonicalize_path "$LOOP_DIR_SHORT" 2>/dev/null || true) + PRIVATE_REAL=$(canonicalize_path "/private$LOOP_DIR_SHORT" 2>/dev/null || true) + if [[ -n "$SHORT_REAL" ]] && [[ "$SHORT_REAL" == "$PRIVATE_REAL" ]] && [[ -d "$SHORT_REAL" ]]; then + LOOP_DIR_FOR_MATCH="$LOOP_DIR_SHORT" + PRIVATE_ALIAS_PREFIX="(/private)?" + fi + ACTIVE_LOOP_DIR_ESCAPED=$(to_lower "$LOOP_DIR_FOR_MATCH" | sed 's/[\\.*^$[(){}+?|]/\\&/g') + if ! echo "$COMMAND_LOWER" | grep -qE "(^|[^[:alnum:]_./~-])${PRIVATE_ALIAS_PREFIX}${ACTIVE_LOOP_DIR_ESCAPED}/round-[12]-todos\.md"; then todos_blocked_message "Bash" >&2 exit 2 fi diff --git a/hooks/loop-edit-validator.sh b/hooks/loop-edit-validator.sh index 6fb2cd19..a2d55878 100755 --- a/hooks/loop-edit-validator.sh +++ b/hooks/loop-edit-validator.sh @@ -203,9 +203,11 @@ fi if is_goal_tracker_path "$FILE_PATH_LOWER"; then GOAL_TRACKER_PATH="$ACTIVE_LOOP_DIR/goal-tracker.md" - # Use canonicalize_path to resolve symlinks (e.g. /var -> /private/var on macOS) - NORMALIZED_FILE_PATH=$(canonicalize_path "$FILE_PATH" 2>/dev/null || _normalize_path "$FILE_PATH") - NORMALIZED_GOAL_TRACKER_PATH=$(canonicalize_path "$GOAL_TRACKER_PATH" 2>/dev/null || _normalize_path "$GOAL_TRACKER_PATH") + # Use canonicalize_path to resolve symlinks (e.g. /var -> /private/var on macOS). + # It returns the raw path when no canonicalizer is available, so normalize its + # result lexically as well to keep accepting /./ and // spellings. + NORMALIZED_FILE_PATH=$(_normalize_path "$(canonicalize_path "$FILE_PATH" 2>/dev/null || printf '%s' "$FILE_PATH")") + NORMALIZED_GOAL_TRACKER_PATH=$(_normalize_path "$(canonicalize_path "$GOAL_TRACKER_PATH" 2>/dev/null || printf '%s' "$GOAL_TRACKER_PATH")") if [[ "$NORMALIZED_FILE_PATH" != "$NORMALIZED_GOAL_TRACKER_PATH" ]]; then goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2 diff --git a/hooks/loop-write-validator.sh b/hooks/loop-write-validator.sh index 42c88257..4d4c8551 100755 --- a/hooks/loop-write-validator.sh +++ b/hooks/loop-write-validator.sh @@ -252,9 +252,11 @@ fi if is_goal_tracker_path "$FILE_PATH_LOWER"; then GOAL_TRACKER_PATH="$ACTIVE_LOOP_DIR/goal-tracker.md" - # Use canonicalize_path to resolve symlinks (e.g. /var -> /private/var on macOS) - NORMALIZED_FILE_PATH=$(canonicalize_path "$FILE_PATH" 2>/dev/null || _normalize_path "$FILE_PATH") - NORMALIZED_GOAL_TRACKER_PATH=$(canonicalize_path "$GOAL_TRACKER_PATH" 2>/dev/null || _normalize_path "$GOAL_TRACKER_PATH") + # Use canonicalize_path to resolve symlinks (e.g. /var -> /private/var on macOS). + # It returns the raw path when no canonicalizer is available, so normalize its + # result lexically as well to keep accepting /./ and // spellings. + NORMALIZED_FILE_PATH=$(_normalize_path "$(canonicalize_path "$FILE_PATH" 2>/dev/null || printf '%s' "$FILE_PATH")") + NORMALIZED_GOAL_TRACKER_PATH=$(_normalize_path "$(canonicalize_path "$GOAL_TRACKER_PATH" 2>/dev/null || printf '%s' "$GOAL_TRACKER_PATH")") if [[ "$NORMALIZED_FILE_PATH" != "$NORMALIZED_GOAL_TRACKER_PATH" ]]; then goal_tracker_blocked_message "$CURRENT_ROUND" "$GOAL_TRACKER_PATH" >&2 diff --git a/tests/test-allowlist-validators.sh b/tests/test-allowlist-validators.sh index fc5c2c98..0feb1c9d 100755 --- a/tests/test-allowlist-validators.sh +++ b/tests/test-allowlist-validators.sh @@ -574,6 +574,129 @@ assert_hook_wrapper_blocked \ "Bash validator blocks nohup+nice stop gate execution" \ "nohup nice -n 5 ./scripts/rlcr-stop-gate.sh" +echo "" +echo "=== Test: Todos Path Identity Across macOS Aliases ===" +echo "" + +setup_test_loop +export CLAUDE_PROJECT_DIR="$TEST_DIR" +ACTIVE_CANONICAL=$(canonicalize_path "$LOOP_DIR") + +run_bash_validator() { + local command="$1" + HOOK_INPUT=$(jq -n --arg command "$command" '{tool_name: "Bash", tool_input: {command: $command}}') + set +e + RESULT=$(echo "$HOOK_INPUT" | "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) + EXIT_CODE=$? + set -e +} + +# Test 40: the canonical spelling of the active loop dir is accepted too +echo "Test 40: Bash validator allows round-1-todos.md via the canonical loop path" +run_bash_validator "echo test > $ACTIVE_CANONICAL/round-1-todos.md" +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Bash validator allows the canonical loop path" +else + fail "Bash validator canonical loop path" "exit 0" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 41: another directory that merely ends with the loop path is not the loop +echo "Test 41: Bash validator blocks round-1-todos.md under a directory ending with the loop path" +run_bash_validator "echo test > /tmp/other${ACTIVE_CANONICAL#/private}/round-1-todos.md" +if [[ $EXIT_CODE -eq 2 ]]; then + pass "Bash validator blocks a suffix-matching directory" +else + fail "Bash validator suffix-matching directory" "exit 2" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 42: same, with the /private spelling nested inside another directory +echo "Test 42: Bash validator blocks round-1-todos.md under a directory containing the canonical loop path" +run_bash_validator "echo test > /tmp/other${ACTIVE_CANONICAL}/round-1-todos.md" +if [[ $EXIT_CODE -eq 2 ]]; then + pass "Bash validator blocks a nested canonical path" +else + fail "Bash validator nested canonical path" "exit 2" "exit $EXIT_CODE, output: $RESULT" +fi + +# On a platform where /private is not an alias (a canonicalizer that returns +# paths unchanged), the other spelling names a different directory. +IDENTITY_BIN="$TEST_DIR/identity-realpath-bin" +mkdir -p "$IDENTITY_BIN" +printf '#!/bin/sh\nprintf "%%s\\n" "$1"\n' > "$IDENTITY_BIN/realpath" +chmod +x "$IDENTITY_BIN/realpath" +if [[ "$ACTIVE_CANONICAL" == /private/* ]]; then + OTHER_SPELLING="${ACTIVE_CANONICAL#/private}" +else + OTHER_SPELLING="/private$ACTIVE_CANONICAL" +fi + +run_bash_validator_without_alias() { + local command="$1" + HOOK_INPUT=$(jq -n --arg command "$command" '{tool_name: "Bash", tool_input: {command: $command}}') + set +e + RESULT=$(echo "$HOOK_INPUT" | CLAUDE_PROJECT_DIR="${ACTIVE_CANONICAL%/.humanize/*}" PATH="$IDENTITY_BIN:$PATH" "$PROJECT_ROOT/hooks/loop-bash-validator.sh" 2>&1) + EXIT_CODE=$? + set -e +} + +# Test 45: without a filesystem alias the other spelling is a different directory +echo "Test 45: Bash validator blocks the other /private spelling when it is not an alias" +run_bash_validator_without_alias "echo test > $OTHER_SPELLING/round-1-todos.md" +if [[ $EXIT_CODE -eq 2 ]]; then + pass "Bash validator blocks an unconfirmed /private alias" +else + fail "Bash validator unconfirmed /private alias" "exit 2" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 46: the exact active loop path is still accepted there +echo "Test 46: Bash validator allows the exact loop path when /private is not an alias" +run_bash_validator_without_alias "echo test > $ACTIVE_CANONICAL/round-1-todos.md" +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Bash validator allows the exact loop path without an alias" +else + fail "Bash validator exact loop path without alias" "exit 0" "exit $EXIT_CODE, output: $RESULT" +fi + +echo "" +echo "=== Test: Goal Tracker Path Normalization Without Canonicalizers ===" +echo "" + +# realpath and python3 that always fail force canonicalize_path to fall back +# to the raw path, as on systems that have neither. +NO_CANON_BIN="$TEST_DIR/no-canonicalizer-bin" +mkdir -p "$NO_CANON_BIN" +for tool in realpath python3; do + printf '#!/bin/sh\nexit 1\n' > "$NO_CANON_BIN/$tool" + chmod +x "$NO_CANON_BIN/$tool" +done +sed -i.bak 's/^current_round: 5$/current_round: 0/' "$LOOP_DIR/state.md" && rm -f "$LOOP_DIR/state.md.bak" + +# Test 43: Write validator accepts a lexically equivalent goal tracker path +echo "Test 43: Write validator allows /./goal-tracker.md without canonicalizers" +HOOK_INPUT=$(jq -n --arg path "$LOOP_DIR/./goal-tracker.md" '{tool_name: "Write", tool_input: {file_path: $path, content: "tracker"}}') +set +e +RESULT=$(echo "$HOOK_INPUT" | PATH="$NO_CANON_BIN:$PATH" "$PROJECT_ROOT/hooks/loop-write-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Write validator normalizes the goal tracker path without canonicalizers" +else + fail "Write validator goal tracker normalization" "exit 0" "exit $EXIT_CODE, output: $RESULT" +fi + +# Test 44: Edit validator accepts a lexically equivalent goal tracker path +echo "Test 44: Edit validator allows //goal-tracker.md without canonicalizers" +HOOK_INPUT=$(jq -n --arg path "$LOOP_DIR//goal-tracker.md" '{tool_name: "Edit", tool_input: {file_path: $path, old_string: "a", new_string: "b"}}') +set +e +RESULT=$(echo "$HOOK_INPUT" | PATH="$NO_CANON_BIN:$PATH" "$PROJECT_ROOT/hooks/loop-edit-validator.sh" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "Edit validator normalizes the goal tracker path without canonicalizers" +else + fail "Edit validator goal tracker normalization" "exit 0" "exit $EXIT_CODE, output: $RESULT" +fi + echo "" echo "=========================================" echo "Test Results"