Found while fixing #1445 (2026-09-08). That issue reported one spelling the tree check accepts and
scripts/carry.py refuses. Two more of the same shape are still open, and both have the same root
cause: the tree check hand-rolls a weaker subset of this file's own escapes_repo_root.
spec/validate.py's tree loop tests each of source and target with three conditions of its own.
escapes_repo_root, defined in the same file, already covers those and two more, and it is called
only for baseline paths, never for trees. Its docstring even names the two the tree check misses.
A backslash spelling escapes the root, and only the carrier notices
"..\\" has PurePosixPath("..\\").parts == ("..\\",), one part, so ".." in parts is false. It
has no leading / and does not reduce to the root, so the validator accepts it.
scripts/carry.py's relative_root accepts it at the same first check, then resolves and calls
relative_to, which on Windows takes C:\hub\.. to C:\ and raises. So the manifest author gets a
green validator and a failing carrier, which is exactly what #1445 reported for "./".
The same spelling also makes the validator itself host-dependent. A source of "spec\\schemas"
fails is_dir() on Linux, since no file carries that literal name, and passes on Windows. A
target has no existence check at all, so "docs\\x" is accepted on both and means one directory
on one host and two on the other.
A symlinked source is green here and refused by the carrier everywhere
The tree loop's existence check is (ROOT / source).is_dir(), which follows a symlink, so a source
naming a link to a directory validates. scripts/carry.py refuses a symlinked component of a tree
source outright, on every platform, so this one does not even need Windows to diverge.
Suggested shape
Call escapes_repo_root from the tree loop rather than restating a subset of it, keeping the
root-reduction helper #1445 added beside it. That closes the backslash and drive-letter cases in
one move and leaves one implementation of the rule instead of two.
The symlink case is a separate check, since escapes_repo_root does not read the filesystem.
Resolving the source and refusing a symlinked component, the way the carrier already does, is the
matching fix.
Not live today
spec/files.json declares one tree, source: ".github/skills", so nothing in the fleet trips
either of these now. Both are gates that would fail to fire rather than defects with a current
victim, which is the same standing #1445 had.
Found while fixing #1445 (2026-09-08). That issue reported one spelling the tree check accepts and
scripts/carry.pyrefuses. Two more of the same shape are still open, and both have the same rootcause: the tree check hand-rolls a weaker subset of this file's own
escapes_repo_root.spec/validate.py's tree loop tests each ofsourceandtargetwith three conditions of its own.escapes_repo_root, defined in the same file, already covers those and two more, and it is calledonly for baseline paths, never for trees. Its docstring even names the two the tree check misses.
A backslash spelling escapes the root, and only the carrier notices
"..\\"hasPurePosixPath("..\\").parts == ("..\\",), one part, so".." in partsis false. Ithas no leading
/and does not reduce to the root, so the validator accepts it.scripts/carry.py'srelative_rootaccepts it at the same first check, then resolves and callsrelative_to, which on Windows takesC:\hub\..toC:\and raises. So the manifest author gets agreen validator and a failing carrier, which is exactly what #1445 reported for
"./".The same spelling also makes the validator itself host-dependent. A source of
"spec\\schemas"fails
is_dir()on Linux, since no file carries that literal name, and passes on Windows. Atargethas no existence check at all, so"docs\\x"is accepted on both and means one directoryon one host and two on the other.
A symlinked source is green here and refused by the carrier everywhere
The tree loop's existence check is
(ROOT / source).is_dir(), which follows a symlink, so a sourcenaming a link to a directory validates.
scripts/carry.pyrefuses a symlinked component of a treesource outright, on every platform, so this one does not even need Windows to diverge.
Suggested shape
Call
escapes_repo_rootfrom the tree loop rather than restating a subset of it, keeping theroot-reduction helper #1445 added beside it. That closes the backslash and drive-letter cases in
one move and leaves one implementation of the rule instead of two.
The symlink case is a separate check, since
escapes_repo_rootdoes not read the filesystem.Resolving the source and refusing a symlinked component, the way the carrier already does, is the
matching fix.
Not live today
spec/files.jsondeclares one tree,source: ".github/skills", so nothing in the fleet tripseither of these now. Both are gates that would fail to fire rather than defects with a current
victim, which is the same standing #1445 had.