fix(hooks): refuse a core.hooksPath that leaves the repository

hook install followed git's hooks path even when core.hooksPath resolved
outside the checkout, then created that directory and wrote post-commit
there. The bytes are Graphify's own hook, not repository-chosen code, but
the write still escapes the repo. Keep in-repo paths such as .husky, and
git's own hooks directory (including a linked worktree's shared .git/hooks).
Anything else falls back to that default.

Fixes #3869

Co-Authored-By: Grok 4.7 <noreply@x.ai>
This commit is contained in:
nothariharanandGrok 4.7 committed 2026-10-02 11:12:17 +05:30
1 parent ef4450d9c2
commit ee35087aea
2 files changed
+137 -2

No files matched your search

+73 -2
View File
@@ -576,6 +576,66 @@ def _reject_windows_path(value: str, source: str) -> None:
)
def _is_within(child: Path, parent: Path) -> bool:
"""True if `child` is `parent` or a path underneath it."""
try:
child.resolve().relative_to(parent.resolve())
except (ValueError, OSError):
return False
return True
def _rev_parse_path(root: Path, flag: str) -> Path | None:
"""Resolve one ``git rev-parse`` path flag against ``root``.
``-c core.hooksPath=`` is not used: an empty value makes ``--git-path hooks``
print ``./`` instead of the real hooks directory.
"""
import subprocess as _sp
try:
res = _sp.run(
["git", "-C", str(root), "rev-parse", flag],
capture_output=True, text=True,
)
except (OSError, FileNotFoundError):
return None
if res.returncode != 0:
return None
raw = res.stdout.strip()
if not raw or any(c in raw for c in ("\n", "\r", "\x00")):
return None
path = Path(raw)
if not path.is_absolute():
path = root / path
return path.resolve()
def _builtin_hooks_dir(root: Path) -> Path | None:
"""Git's own hooks directory, ignoring core.hooksPath.
``--git-path hooks`` follows core.hooksPath, so the default is derived from
the common git dir. A linked worktree's hooks live in the main repo's
``.git/hooks``, which is outside the worktree root.
"""
for flag in ("--git-common-dir", "--git-dir"):
git_dir = _rev_parse_path(root, flag)
if git_dir is not None:
return (git_dir / "hooks").resolve()
return None
def _hooks_path_allowed(root: Path, candidate: Path) -> bool:
"""Allow in-repo hook dirs (Husky) and git's own hooks dir only.
A core.hooksPath that resolves anywhere else is repository-controlled
local config. Honoring it makes `hook install` write outside the repo (#3869).
"""
if _is_within(candidate, root):
return True
builtin = _builtin_hooks_dir(root)
return builtin is not None and candidate.resolve() == builtin.resolve()
def _hooks_dir(root: Path) -> Path:
"""Return the git hooks directory, respecting core.hooksPath if set (e.g. Husky).
@@ -616,8 +676,19 @@ def _hooks_dir(root: Path) -> Path:
if raw and not any(c in raw for c in ("\n", "\r", "\x00")):
_reject_windows_path(raw, "git rev-parse --git-path hooks")
d = (root / raw).resolve()
d.mkdir(parents=True, exist_ok=True)
return d
if _hooks_path_allowed(root, d):
d.mkdir(parents=True, exist_ok=True)
return d
print(
f"[graphify hooks] refusing hooks path {d}: it is outside "
f"{root.resolve()}. Installing into the default git hooks "
f"directory instead (#3869).",
file=sys.stderr,
)
default = _builtin_hooks_dir(root)
if default is not None:
default.mkdir(parents=True, exist_ok=True)
return default
except (OSError, FileNotFoundError):
pass
d = root / ".git" / "hooks"
+64
View File
@@ -589,6 +589,70 @@ def test_posix_custom_hookspath_still_works(tmp_path):
assert (repo / ".husky" / "post-commit").exists()
def test_hookspath_outside_repo_falls_back_to_default(tmp_path, capsys):
"""#3869: an absolute core.hooksPath outside the repo must not be created.
The setting lives in local git config (a zip of .git can carry it; a clone
does not). hook install used to mkdir that path and write post-commit there.
"""
repo = _make_git_repo(tmp_path / "repo")
outside = tmp_path / "outside"
_set_hookspath(repo, str(outside))
msg = install(repo)
err = capsys.readouterr().err
assert "refusing hooks path" in err
assert not outside.exists()
assert (repo / ".git" / "hooks" / "post-commit").exists()
assert (repo / ".git" / "hooks" / "post-checkout").exists()
assert ".git" in msg and "hooks" in msg
def test_hookspath_relative_escape_falls_back_to_default(tmp_path, capsys):
"""#3869: a relative core.hooksPath that resolves outside the repo is refused."""
repo = _make_git_repo(tmp_path / "repo")
_set_hookspath(repo, "../escaped")
_hooks_dir(repo)
err = capsys.readouterr().err
assert "refusing hooks path" in err
assert not (tmp_path / "escaped").exists()
assert _hooks_dir(repo) == (repo / ".git" / "hooks").resolve()
def test_linked_worktree_keeps_its_default_hooks_dir(tmp_path):
"""A linked worktree's hooks live under the common git dir, outside the
worktree root. That default must still be used when core.hooksPath is unset."""
main = tmp_path / "main"
main.mkdir()
subprocess.run(["git", "init", str(main)], check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "config", "user.email", "t@example.com"],
check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "config", "user.name", "t"],
check=True, capture_output=True)
(main / "f").write_text("x\n", encoding="utf-8")
subprocess.run(["git", "-C", str(main), "add", "f"], check=True, capture_output=True)
subprocess.run(["git", "-C", str(main), "commit", "-m", "x"],
check=True, capture_output=True)
wt = tmp_path / "wt"
subprocess.run(["git", "-C", str(main), "worktree", "add", "-q", str(wt), "-b", "wt"],
check=True, capture_output=True)
expected = subprocess.run(
["git", "-C", str(wt), "rev-parse", "--git-path", "hooks"],
check=True, capture_output=True, text=True,
).stdout.strip()
got = _hooks_dir(wt)
assert got == (wt / expected).resolve()
assert _is_within_repo_git(got, main)
assert not (wt / ".git" / "hooks").exists()
def _is_within_repo_git(hooks: Path, main: Path) -> bool:
try:
hooks.resolve().relative_to((main / ".git").resolve())
except ValueError:
return False
return True
def test_default_hooks_dir_unaffected(tmp_path):
"""No core.hooksPath -> normal .git/hooks install, no rejection."""
repo = _make_git_repo(tmp_path)