Files
hermes-agent/tests/cli/test_worktree_security.py
T
solyanviktor-star 607f647141 fix(cli): read .worktreeinclude and .gitignore as UTF-8 in worktree setup
_setup_worktree read both files with the locale default encoding. On a
cp1251/GBK Windows machine a UTF-8 include list either decodes to
mojibake paths (non-ASCII entries silently not copied) or raises
UnicodeDecodeError, which the enclosing handler logs at DEBUG and
swallows — no include is copied at all, so the worktree starts without
.env/keys and the agent breaks invisibly. A Notepad BOM likewise glues
to the first include entry on every platform, and to the first
.gitignore line, defeating the '.worktrees/' membership check and
appending a duplicate entry on each run.

Read both files with utf-8-sig + errors=replace, matching the canonical
.env readers in hermes_cli/config.py (utf-8-sig because Notepad adds a
BOM) and the UTF-8 append this same block already performs on
.gitignore.

Regression tests exercise the real cli._setup_worktree: the two BOM
tests fail without the fix on any platform, the non-ASCII include test
additionally reproduces the Windows locale failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:10:39 -07:00

192 lines
6.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Security-focused integration tests for CLI worktree setup."""
import subprocess
from pathlib import Path
import pytest
@pytest.fixture
def git_repo(tmp_path):
"""Create a temporary git repo for testing real cli._setup_worktree behavior."""
repo = tmp_path / "test-repo"
repo.mkdir()
subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True, capture_output=True)
(repo / "README.md").write_text("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True)
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo, check=True, capture_output=True)
return repo
def _force_remove_worktree(info: dict | None) -> None:
if not info:
return
subprocess.run(
["git", "worktree", "remove", info["path"], "--force"],
cwd=info["repo_root"],
capture_output=True,
check=False,
)
subprocess.run(
["git", "branch", "-D", info["branch"]],
cwd=info["repo_root"],
capture_output=True,
check=False,
)
class TestWorktreeIncludeSecurity:
def test_rejects_parent_directory_file_traversal(self, git_repo):
import cli as cli_mod
outside_file = git_repo.parent / "sensitive.txt"
outside_file.write_text("SENSITIVE DATA")
(git_repo / ".worktreeinclude").write_text("../sensitive.txt\n")
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
wt_path = Path(info["path"])
assert not (wt_path.parent / "sensitive.txt").exists()
assert not (wt_path / "../sensitive.txt").resolve().exists()
finally:
_force_remove_worktree(info)
def test_rejects_parent_directory_directory_traversal(self, git_repo):
import cli as cli_mod
outside_dir = git_repo.parent / "outside-dir"
outside_dir.mkdir()
(outside_dir / "secret.txt").write_text("SENSITIVE DIR DATA")
(git_repo / ".worktreeinclude").write_text("../outside-dir\n")
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
wt_path = Path(info["path"])
escaped_dir = wt_path.parent / "outside-dir"
assert not escaped_dir.exists()
assert not escaped_dir.is_symlink()
finally:
_force_remove_worktree(info)
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
import cli as cli_mod
outside_file = git_repo.parent / "linked-secret.txt"
outside_file.write_text("LINKED SECRET")
(git_repo / "leak.txt").symlink_to(outside_file)
(git_repo / ".worktreeinclude").write_text("leak.txt\n")
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert not (Path(info["path"]) / "leak.txt").exists()
finally:
_force_remove_worktree(info)
def test_allows_valid_file_include(self, git_repo):
import cli as cli_mod
(git_repo / ".env").write_text("SECRET=***\n")
(git_repo / ".worktreeinclude").write_text(".env\n")
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
copied = Path(info["path"]) / ".env"
assert copied.exists()
assert copied.read_text() == "SECRET=***\n"
finally:
_force_remove_worktree(info)
def test_allows_valid_directory_include(self, git_repo):
import cli as cli_mod
assets_dir = git_repo / ".venv" / "lib"
assets_dir.mkdir(parents=True)
(assets_dir / "marker.txt").write_text("venv marker")
(git_repo / ".worktreeinclude").write_text(".venv\n")
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
linked_dir = Path(info["path"]) / ".venv"
assert linked_dir.is_symlink()
assert (linked_dir / "lib" / "marker.txt").read_text() == "venv marker"
finally:
_force_remove_worktree(info)
class TestWorktreeIncludeEncoding:
"""The include list and .gitignore are UTF-8 files; reading them with the
locale default breaks Windows (cp1251/GBK mojibake or UnicodeDecodeError,
swallowed at DEBUG so no include is copied), and a Notepad BOM glues to
the first line on every platform."""
def test_bom_in_worktreeinclude_does_not_hide_first_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".env").write_text("SECRET=***\n")
# Notepad-style UTF-8 with BOM; the BOM must not become part of the
# first entry's path.
(git_repo / ".worktreeinclude").write_bytes(".env\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / ".env").exists()
finally:
_force_remove_worktree(info)
def test_non_ascii_worktreeinclude_entry_copied(self, git_repo):
import cli as cli_mod
secret = git_repo / "секреты.env"
secret.write_text("SECRET=***\n", encoding="utf-8")
(git_repo / ".worktreeinclude").write_bytes(
"# ключи агента\nсекреты.env\n".encode("utf-8")
)
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
assert (Path(info["path"]) / "секреты.env").exists()
finally:
_force_remove_worktree(info)
def test_bom_in_gitignore_does_not_duplicate_worktrees_entry(self, git_repo):
import cli as cli_mod
(git_repo / ".gitignore").write_bytes(".worktrees/\n".encode("utf-8"))
info = None
try:
info = cli_mod._setup_worktree(str(git_repo))
assert info is not None
lines = (
(git_repo / ".gitignore")
.read_text(encoding="utf-8-sig")
.splitlines()
)
assert lines.count(".worktrees/") == 1, (
"BOM glued to the first line must not defeat the membership "
f"check and duplicate the entry: {lines!r}"
)
finally:
_force_remove_worktree(info)