mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-02 07:11:49 +00:00
fix(security): guard os.chmod(parent) against / and top-level dirs
Five call sites do os.chmod(path.parent, 0o700) without checking that the parent resolves to a safe directory. If HERMES_HOME or another path env var resolves to /, the chmod strips traversal permission from the root inode and bricks the entire host. Add secure_parent_dir() to hermes_constants.py that refuses to chmod / or any top-level directory (depth < 2). Replace all 5 call sites with this helper. Fixes #25821
This commit is contained in:
parent
3bbe980115
commit
4ead464f97
5 changed files with 127 additions and 22 deletions
|
|
@ -12,6 +12,7 @@ from hermes_constants import (
|
|||
get_default_hermes_root,
|
||||
is_container,
|
||||
parse_reasoning_effort,
|
||||
secure_parent_dir,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -171,3 +172,95 @@ class TestParseReasoningEffort:
|
|||
"""
|
||||
documented = {"minimal", "low", "medium", "high", "xhigh"}
|
||||
assert documented.issubset(set(VALID_REASONING_EFFORTS))
|
||||
|
||||
|
||||
class TestSecureParentDir:
|
||||
"""Tests for secure_parent_dir() — prevents chmod on / or top-level dirs."""
|
||||
|
||||
def test_safe_path_calls_chmod(self, tmp_path, monkeypatch):
|
||||
"""Normal nested path (depth >= 3) should call os.chmod."""
|
||||
safe_dir = tmp_path / "home" / "user" / ".hermes"
|
||||
safe_dir.mkdir(parents=True)
|
||||
target = safe_dir / "auth.json"
|
||||
target.touch()
|
||||
|
||||
called_with = []
|
||||
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
||||
|
||||
secure_parent_dir(target)
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0] == (str(safe_dir), 0o700)
|
||||
|
||||
def test_root_dir_skipped(self, monkeypatch):
|
||||
"""Parent resolving to / must NOT be chmod'd."""
|
||||
called_with = []
|
||||
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
||||
|
||||
# Path("/foo").parent == Path("/")
|
||||
secure_parent_dir(Path("/foo"))
|
||||
assert called_with == []
|
||||
|
||||
def test_top_level_dir_skipped(self, monkeypatch):
|
||||
"""Parent resolving to a top-level dir (depth 2) must NOT be chmod'd."""
|
||||
called_with = []
|
||||
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
||||
|
||||
# Path("/usr/foo").parent == Path("/usr") — depth 2
|
||||
secure_parent_dir(Path("/usr/foo"))
|
||||
assert called_with == []
|
||||
|
||||
def test_two_component_path_skipped(self, monkeypatch):
|
||||
"""Parent with < 3 resolved parts must NOT be chmod'd.
|
||||
|
||||
Uses monkeypatch to avoid macOS firmlink resolution of /home.
|
||||
"""
|
||||
called_with = []
|
||||
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
||||
|
||||
# Mock Path.resolve to return a short path regardless of OS quirks
|
||||
original_resolve = Path.resolve
|
||||
def mock_resolve(self):
|
||||
if str(self) == "/x/y":
|
||||
return Path("/x")
|
||||
return original_resolve(self)
|
||||
monkeypatch.setattr(Path, "resolve", mock_resolve)
|
||||
|
||||
secure_parent_dir(Path("/x/y"))
|
||||
assert called_with == []
|
||||
|
||||
def test_oserror_suppressed(self, tmp_path, monkeypatch):
|
||||
"""OSError from chmod should be silently caught."""
|
||||
safe_dir = tmp_path / "a" / "b" / "c"
|
||||
safe_dir.mkdir(parents=True)
|
||||
target = safe_dir / "file.json"
|
||||
target.touch()
|
||||
|
||||
def raise_oserror(p, m):
|
||||
raise OSError("permission denied")
|
||||
|
||||
monkeypatch.setattr(os, "chmod", raise_oserror)
|
||||
# Should not raise
|
||||
secure_parent_dir(target)
|
||||
|
||||
def test_symlink_resolved(self, tmp_path, monkeypatch):
|
||||
"""Symlinks should be resolved before checking depth."""
|
||||
real_dir = tmp_path / "a" / "b"
|
||||
real_dir.mkdir(parents=True)
|
||||
target = real_dir / "file.json"
|
||||
target.touch()
|
||||
|
||||
# Create a symlink with fewer path components
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(real_dir)
|
||||
link_target = link / "file.json"
|
||||
|
||||
called_with = []
|
||||
monkeypatch.setattr(os, "chmod", lambda p, m: called_with.append((str(p), m)))
|
||||
|
||||
# Even though /tmp/link has only 3 parts, the resolved path has 4
|
||||
# The resolved parent (real_dir) has depth 4, so it should be chmod'd
|
||||
secure_parent_dir(link_target)
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0] == (str(real_dir), 0o700)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue