Skip to content

fix: rewrite Windows backslash paths in hook commands#609

Open
danielmeppiel wants to merge 2 commits intomainfrom
fix/hook-windows-backslash-paths
Open

fix: rewrite Windows backslash paths in hook commands#609
danielmeppiel wants to merge 2 commits intomainfrom
fix/hook-windows-backslash-paths

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

Fixes #520

Problem

The windows key in hook JSON uses backslash paths (.\\secrets-scanner\\scan-secrets.ps1), but _rewrite_command_for_target() only matched Unix-style ./ references. Result: command paths were rewritten correctly but windows paths were silently skipped -- scripts not copied, paths not rewritten.

Reported by @dstamand-msft with a concrete reproduction showing the deployed hook JSON has the command path rewritten but windows still pointing to the original relative path.

Fix

Extend the relative-path regex from r'(\./[^\s]+)' to r'(\.[/\\\\][^\s]+)' to match both ./ and .\ prefixes. Backslashes are normalized to forward slashes for path resolution and file lookup.

Before:

{
  "command": ".github/hooks/scripts/pkg/scan.sh",
  "windows": "pwsh -File .\\\\secrets-scanner\\\\scan.ps1"
}

After:

{
  "command": ".github/hooks/scripts/pkg/scan.sh",
  "windows": "pwsh -File .github/hooks/scripts/pkg/secrets-scanner/scan.ps1"
}

Tests

4 new tests covering:

  • Backslash relative path rewrite (.\\path\\script.ps1)
  • End-to-end flat format with mixed forward/backslash
  • End-to-end nested Claude format with mixed forward/backslash
  • Forward-slash regression (still works)

3678 tests pass.

The relative-path regex in _rewrite_command_for_target only matched
Unix-style ./ references. Windows-style .\ paths in the 'windows' hook
key were silently skipped -- scripts not copied, paths not rewritten.

Extend the regex from r'(\./[^\s]+)' to r'(\.[/\\][^\s]+)' to match
both ./ and .\ prefixes. Normalize backslashes to forward slashes for
path resolution and file lookup.

Fixes #520

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 7, 2026 07:24
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes hook command rewriting so Windows-style relative script paths using backslashes (for example, .\scripts\scan.ps1 and ${CLAUDE_PLUGIN_ROOT}\...) are detected, normalized, and rewritten to the installed scripts location, ensuring referenced scripts are copied and hook JSON is updated correctly.

Changes:

  • Expand ${CLAUDE_PLUGIN_ROOT} and relative-path regex matching to accept both / and \, and normalize \ to / before Path resolution.
  • Add unit tests to cover backslash-path rewriting scenarios (direct relative paths, prefixed commands, plugin root, traversal rejection, and end-to-end hook JSON rewrites).
Show a summary per file
File Description
src/apm_cli/integration/hook_integrator.py Extends command rewriting to match and normalize Windows backslash path variants for script discovery + rewriting.
tests/unit/integration/test_hook_integrator.py Adds coverage for backslash rewriting, including end-to-end _rewrite_hooks_data() behavior.

Copilot's findings

Comments suppressed due to low confidence (3)

tests/unit/integration/test_hook_integrator.py:1403

  • test_rewrite_hooks_data_windows_backslash_flat() appears to accidentally include the entire body (and docstring string literal) of the existing forward-slash windows-key test starting at line 1399. This makes the mid-function triple-quoted string a no-op (not a docstring) and effectively combines two independent test cases into one, which is easy to miss and hard to maintain. Split this into separate def test functions (backslash case vs forward-slash regression) and remove the stray in-function string literal/setup duplication.
        assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in hook["bash"]
        assert ".github/hooks/scripts/my-pkg/scripts/validate.ps1" in hook["windows"]
        assert "\\" not in hook["windows"]
        assert len(scripts) == 2
        """Test _rewrite_hooks_data handles windows key in flat format (GitHub Copilot)."""
        pkg_dir = temp_project / "pkg"
        (pkg_dir / "scripts").mkdir(parents=True)
        (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash")
        (pkg_dir / "scripts" / "validate.ps1").write_text("Write-Host 'ok'")

src/apm_cli/integration/hook_integrator.py:248

  • The path-traversal guard here is implemented inline via resolve() + Path.is_relative_to(). Since this code builds filesystem paths from hook JSON content (external/package-controlled input), it should use the centralized path-security helpers (validate_path_segments on the normalized relative path and ensure_path_within for containment) to keep behavior consistent and avoid drift across call sites.

This issue also appears on line 259 of the same file.

            # Normalize backslashes to forward slashes before Path construction
            # (on Unix, Path treats backslashes as literal filename chars)
            rel_path = match.group(1).replace('\\', '/').lstrip('/')

            source_file = (package_path / rel_path).resolve()
            # Reject path traversal outside the package directory
            if not source_file.is_relative_to(package_path.resolve()):
                continue

src/apm_cli/integration/hook_integrator.py:270

  • Same as above for ./ / .\ rewriting: the code constructs source_file = (resolve_base / rel_path).resolve() and then checks is_relative_to(package_path.resolve()). Prefer validate_path_segments(rel_path, context=...) (after backslash normalization) and ensure_path_within(resolve_base / rel_path, package_path) so traversal is rejected through the shared, documented mechanism.
        # Resolve from hook file's directory if available, else fall back to package root
        resolve_base = hook_file_dir if hook_file_dir else package_path
        rel_pattern = r'(\.[\\/][^\s]+)'
        for match in re.finditer(rel_pattern, new_command):
            rel_ref = match.group(1)
            # Normalize to forward slashes for path resolution
            rel_path = rel_ref[2:].replace('\\', '/')

            source_file = (resolve_base / rel_path).resolve()
            # Reject path traversal outside the package directory
            if not source_file.is_relative_to(package_path.resolve()):
                continue
  • Files reviewed: 2/2 changed files
  • Comments generated: 0

Tests sharing a temp_project fixture would fail when multiple tests
created the same subdirectory. Add exist_ok=True across all mkdir
calls in the test file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Hook integrator does not process 'windows' property – scripts not copied and paths not rewritten

2 participants