Skip to content

bug: is_absolute() repo-relative guards accept paths that escape the repo on Windows #72

Description

@Hotragn

What happened?

Two guards that promise a path is "repo-relative" accept paths that escape the repo on Windows, because they are built on Path.is_absolute().

PureWindowsPath reports two anchored forms as relative:

input is_absolute() drive root
/tmp/wiki False '' '\'
C:wiki False 'C:' ''

Both still discard the left-hand side when joined, which is exactly what the guard exists to prevent:

PureWindowsPath("C:/repo") / "/tmp/wiki"   # -> C:/tmp/wiki
PureWindowsPath("D:/repo") / "C:wiki"      # -> C:wiki

What did you expect?

The guards' own error messages state the intent:

  • SourceRuntimeContext.ignored_directories"source runtime ignored directories must be repo-relative"
  • require_default_almanac_root"Almanac root must be a repo-relative path"

Reproduction

The repo already asserts this input is unsafe, and the assertion fails on Windows:

uv run pytest "tests/test_filesystem_source_runtime.py::test_source_runtime_context_rejects_unsafe_ignored_directories" -q
# FAILED ...[directory0] - Failed: DID NOT RAISE ValidationError

That directory0 parameter is Path("/tmp/wiki").

Environment

  • OS: Windows 11
  • Python version: 3.13
  • CodeAlmanac version: 0.4.7 / main at 0f15350
  • Install method: local checkout
  • Almanac root: almanac/

Logs or output

directory = WindowsPath('/tmp/wiki')

    @pytest.mark.parametrize(
        "directory",
        (Path("/tmp/wiki"), Path("."), Path("../wiki"), Path("docs/../wiki")),
    )
    def test_source_runtime_context_rejects_unsafe_ignored_directories(directory):
>       with pytest.raises(ValidationError):
E       Failed: DID NOT RAISE ValidationError

Neither later check catches it either: len(parts) is 3, and parts is ('\', 'tmp', 'wiki'), so the {"..", "~"} filter never matches.

Extra context

This matters most for ignored_directories, since source runtime context is populated from wiki page frontmatter that agents author — so the value is not necessarily developer-typed. require_default_almanac_root is lower severity because its later != almanac check already rejects these inputs; that half is defence in depth rather than a live escape, but it is the same latent mistake and becomes load-bearing if the "fixed at almanac/" pin is ever relaxed.

Current blast radius is limited: the ignored_directories sink is pathspec pattern generation (integrations/sources/filesystem/ignore.py), which yields an anchored /tmp/wiki/ pattern rather than a filesystem traversal. So it is a broken invariant with a currently-benign sink — but the guard is the stated defence and it does not hold off POSIX.

I have a fix up in #65: both guards share one core.paths.is_rooted predicate that rejects any anchor. On POSIX a root implies an absolute path and drive is always empty, so it is provably equivalent to the old check there and macOS/Linux behaviour is unchanged. The tests use explicit PureWindowsPath/PurePosixPath rather than the platform-native Path, so both platforms' semantics are pinned on any runner — meaning it is fully reviewable on the current Linux-only CI without a Windows machine.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions