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
105 changes: 102 additions & 3 deletions hooks/lib/project-root.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
# (e.g. /Users/x vs /private/Users/x on macOS, or /var vs /private/var)
# do not diverge between setup-time and hook-time resolution.
#
# On MSYS/MinGW/Cygwin the resolved path is additionally normalized to the
# POSIX spelling, because a single directory has two textual forms there
# (C:/x and /c/x) that never compare equal. See `to_posix_path`.
#
# Path-comparison sites in validators must mirror this by canonicalizing
# the user-provided side as well; use the companion `canonicalize_path`
# helper below.
Expand All @@ -25,6 +29,83 @@ if [[ -n "${_HUMANIZE_PROJECT_ROOT_SOURCED:-}" ]]; then
fi
_HUMANIZE_PROJECT_ROOT_SOURCED=1

# _humanize_is_windows_shell
#
# True under MSYS/MinGW/Cygwin, where the same filesystem location has two
# spellings. False everywhere else, which keeps `to_posix_path` a no-op on
# real POSIX systems -- a Linux filename may legitimately contain a
# backslash, and must not be rewritten.
#
_humanize_is_windows_shell() {
case "${OSTYPE:-}" in
msys | cygwin | win32) return 0 ;;
esac
case "$(uname -s 2>/dev/null || true)" in
MINGW* | MSYS* | CYGWIN*) return 0 ;;
esac
return 1
}

# to_posix_path
#
# Normalizes a Windows path spelling to the MSYS/Cygwin POSIX form so that
# paths originating from different sources can be compared as strings:
#
# C:\Users\x\proj -> /c/Users/x/proj
# C:/Users/x/proj -> /c/Users/x/proj
# /c/Users/x/proj -> /c/Users/x/proj (already POSIX, returned unchanged)
#
# This matters because the two sides of a containment check come from
# sources that disagree: `git rev-parse --show-toplevel` and CLAUDE_PROJECT_DIR
# yield the drive-letter form, while bash `pwd` yields the POSIX form. MSYS
# realpath preserves whichever form it is handed instead of normalizing
# between them, so canonicalization alone does not make them comparable.
#
# Any drive letter and any path are supported; nothing is hardcoded.
#
# Empty input prints nothing and returns 0.
#
to_posix_path() {
local path="$1"
if [[ -z "$path" ]]; then
return 0
fi

if ! _humanize_is_windows_shell; then
printf '%s\n' "$path"
return 0
fi

# Only drive-letter or backslash spellings need rewriting. A bare
# drive-relative path such as `C:foo` is deliberately left alone: it
# resolves against a per-drive working directory this helper cannot know.
case "$path" in
[A-Za-z]:[\\/]* | [A-Za-z]: | *\\*) ;;
*)
printf '%s\n' "$path"
return 0
;;
esac

if command -v cygpath >/dev/null 2>&1; then
local converted
if converted=$(cygpath -u "$path" 2>/dev/null) && [[ -n "$converted" ]]; then
printf '%s\n' "$converted"
return 0
fi
fi

# Fallback for a Windows shell without cygpath: rewrite by hand.
path="${path//\\//}"
if [[ "$path" =~ ^([A-Za-z]):(/.*)?$ ]]; then
local drive="${BASH_REMATCH[1]}"
local rest="${BASH_REMATCH[2]:-}"
drive=$(printf '%s' "$drive" | tr '[:upper:]' '[:lower:]')
path="/${drive}${rest}"
fi
printf '%s\n' "$path"
}

# resolve_project_root
#
# Prints the resolved project root to stdout. Returns 0 on success,
Expand All @@ -47,6 +128,12 @@ resolve_project_root() {
return 1
fi

# Both sources speak the drive-letter dialect on Windows: Claude Code
# exports CLAUDE_PROJECT_DIR as `F:\Project\Public`, and git prints
# `F:/Project/Public`. Normalize before canonicalizing so the result is
# comparable with paths built from `pwd`.
root="$(to_posix_path "$root")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize remaining realpath comparisons too

On MSYS/MinGW this switches PROJECT_ROOT to /c/..., but the methodology-analysis read guard still compares raw realpath "$FILE_PATH" against raw realpath "$PROJECT_ROOT"/$PROJECT_ROOT in hooks/loop-read-validator.sh lines 91 and 154-158. When Claude supplies a drive-letter path such as C:/repo/src/x during methodology analysis, MSYS realpath preserves that spelling while PROJECT_ROOT is now /c/repo, so neither prefix check matches and project files are allowed to be read despite that phase's project-read block. Please run those remaining realpath outputs through the same normalization before comparing.

Useful? React with 👍 / 👎.


local canonical
canonical=$(canonicalize_path "$root")
printf '%s\n' "${canonical:-$root}"
Expand Down Expand Up @@ -75,16 +162,24 @@ canonicalize_path_prefix() {
return 0
fi

path="$(to_posix_path "$path")"

local parent base parent_real
parent=$(dirname -- "$path")
base=$(basename -- "$path")

if parent_real=$(realpath "$parent" 2>/dev/null) && [[ -n "$parent_real" ]]; then
parent_real="$(to_posix_path "$parent_real")"
printf '%s/%s\n' "${parent_real%/}" "$base"
return 0
fi

if command -v python3 >/dev/null 2>&1; then
# The python3 fallback is POSIX-only. A native Windows interpreter reads
# `/c/x` as drive-relative and answers `C:\c\x` -- a different directory
# -- so on a Windows shell we prefer returning the normalized input over
# a confidently wrong answer. MSYS always ships realpath, so this costs
# nothing in practice.
if ! _humanize_is_windows_shell && command -v python3 >/dev/null 2>&1; then
parent_real=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$parent" 2>/dev/null || true)
if [[ -n "$parent_real" ]]; then
printf '%s/%s\n' "${parent_real%/}" "$base"
Expand Down Expand Up @@ -116,10 +211,12 @@ canonicalize_path() {
return 0
fi

path="$(to_posix_path "$path")"

local canonical=""

if canonical=$(realpath "$path" 2>/dev/null) && [[ -n "$canonical" ]]; then
printf '%s\n' "$canonical"
to_posix_path "$canonical"
return 0
fi

Expand All @@ -128,11 +225,13 @@ canonicalize_path() {
parent=$(dirname -- "$path")
base=$(basename -- "$path")
if canonical=$(realpath "$parent" 2>/dev/null) && [[ -n "$canonical" ]]; then
canonical="$(to_posix_path "$canonical")"
printf '%s/%s\n' "${canonical%/}" "$base"
return 0
fi

if command -v python3 >/dev/null 2>&1; then
# POSIX-only fallback; see the note in canonicalize_path_prefix.
if ! _humanize_is_windows_shell && command -v python3 >/dev/null 2>&1; then
canonical=$(python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$path" 2>/dev/null || true)
if [[ -n "$canonical" ]]; then
printf '%s\n' "$canonical"
Expand Down
5 changes: 4 additions & 1 deletion scripts/setup-rlcr-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,10 @@ RESOLVED_PLAN_DIR=$(cd "$PLAN_DIR" 2>/dev/null && pwd) || {
echo " This may indicate permission issues or broken symlinks in the path" >&2
exit 1
}
REAL_PLAN_PATH="$RESOLVED_PLAN_DIR/$(basename "$FULL_PLAN_PATH")"
# `pwd` already yields the POSIX spelling on MSYS, matching the normalized
# PROJECT_ROOT. Normalize explicitly anyway so both sides of the comparison
# below are guaranteed to speak one dialect regardless of shell.
REAL_PLAN_PATH="$(to_posix_path "$RESOLVED_PLAN_DIR/$(basename "$FULL_PLAN_PATH")")"
if [[ ! "$REAL_PLAN_PATH" = "$PROJECT_ROOT"/* ]]; then
echo "Error: Plan file must be within project directory" >&2
exit 1
Expand Down
1 change: 1 addition & 0 deletions tests/run-all-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ TEST_SUITES=(
"test-gen-plan.sh"
"test-refine-plan.sh"
"test-task-tag-routing.sh"
"test-path-normalization.sh"
"test-config-merge.sh"
"test-config-error-handling.sh"
"test-codex-hook-install.sh"
Expand Down
Loading