Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions hooks/loop-bash-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions hooks/loop-edit-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions hooks/loop-write-validator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 123 additions & 0 deletions tests/test-allowlist-validators.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <loop>/./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 <loop>//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"
Expand Down