mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
_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>
191 lines
6.6 KiB
Python
191 lines
6.6 KiB
Python
"""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)
|