Files
hermes-agent/tests/tools/test_credential_files.py
Teknium 7f78deebe7 fix: apply same path traversal checks to config-based credential files
_load_config_files() had the same hermes_home / item pattern without
containment checks. While config.yaml is user-controlled (lower threat
than skill frontmatter), defense in depth prevents exploitation via
config injection or copy-paste mistakes.
2026-03-31 12:16:37 -07:00

361 lines
13 KiB
Python

"""Tests for credential file passthrough and skills directory mounting."""
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from tools.credential_files import (
clear_credential_files,
get_credential_file_mounts,
get_skills_directory_mount,
iter_skills_files,
register_credential_file,
register_credential_files,
reset_config_cache,
)
@pytest.fixture(autouse=True)
def _clean_state():
"""Reset module state between tests."""
clear_credential_files()
reset_config_cache()
yield
clear_credential_files()
reset_config_cache()
class TestRegisterCredentialFiles:
def test_dict_with_path_key(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "token.json").write_text("{}")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([{"path": "token.json"}])
assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert mounts[0]["host_path"] == str(hermes_home / "token.json")
assert mounts[0]["container_path"] == "/root/.hermes/token.json"
def test_dict_with_name_key_fallback(self, tmp_path):
"""Skills use 'name' instead of 'path' — both should work."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "google_token.json").write_text("{}")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"name": "google_token.json", "description": "OAuth token"},
])
assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert "google_token.json" in mounts[0]["container_path"]
def test_string_entry(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "secret.key").write_text("key")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files(["secret.key"])
assert missing == []
mounts = get_credential_file_mounts()
assert len(mounts) == 1
def test_missing_file_reported(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"name": "does_not_exist.json"},
])
assert "does_not_exist.json" in missing
assert get_credential_file_mounts() == []
def test_path_takes_precedence_over_name(self, tmp_path):
"""When both path and name are present, path wins."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
(hermes_home / "real.json").write_text("{}")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
missing = register_credential_files([
{"path": "real.json", "name": "wrong.json"},
])
assert missing == []
mounts = get_credential_file_mounts()
assert "real.json" in mounts[0]["container_path"]
class TestSkillsDirectoryMount:
def test_returns_mount_when_skills_dir_exists(self, tmp_path):
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "test-skill").mkdir()
(skills_dir / "test-skill" / "SKILL.md").write_text("# test")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount is not None
assert mount["host_path"] == str(skills_dir)
assert mount["container_path"] == "/root/.hermes/skills"
def test_returns_none_when_no_skills_dir(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount is None
def test_custom_container_base(self, tmp_path):
hermes_home = tmp_path / ".hermes"
(hermes_home / "skills").mkdir(parents=True)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount(container_base="/home/user/.hermes")
assert mount["container_path"] == "/home/user/.hermes/skills"
def test_symlinks_are_sanitized(self, tmp_path):
"""Symlinks in skills dir should be excluded from the mount."""
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "legit.md").write_text("# real skill")
# Create a symlink pointing outside the skills tree
secret = tmp_path / "secret.txt"
secret.write_text("TOP SECRET")
(skills_dir / "evil_link").symlink_to(secret)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount is not None
# The mount path should be a sanitized copy, not the original
safe_path = Path(mount["host_path"])
assert safe_path != skills_dir
# Legitimate file should be present
assert (safe_path / "legit.md").exists()
assert (safe_path / "legit.md").read_text() == "# real skill"
# Symlink should NOT be present
assert not (safe_path / "evil_link").exists()
def test_no_symlinks_returns_original_dir(self, tmp_path):
"""When no symlinks exist, the original dir is returned (no copy)."""
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
skills_dir.mkdir(parents=True)
(skills_dir / "skill.md").write_text("ok")
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
mount = get_skills_directory_mount()
assert mount["host_path"] == str(skills_dir)
class TestIterSkillsFiles:
def test_returns_files_skipping_symlinks(self, tmp_path):
hermes_home = tmp_path / ".hermes"
skills_dir = hermes_home / "skills"
(skills_dir / "cat" / "myskill").mkdir(parents=True)
(skills_dir / "cat" / "myskill" / "SKILL.md").write_text("# skill")
(skills_dir / "cat" / "myskill" / "scripts").mkdir()
(skills_dir / "cat" / "myskill" / "scripts" / "run.sh").write_text("#!/bin/bash")
# Add a symlink that should be filtered
secret = tmp_path / "secret"
secret.write_text("nope")
(skills_dir / "cat" / "myskill" / "evil").symlink_to(secret)
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
files = iter_skills_files()
paths = {f["container_path"] for f in files}
assert "/root/.hermes/skills/cat/myskill/SKILL.md" in paths
assert "/root/.hermes/skills/cat/myskill/scripts/run.sh" in paths
# Symlink should be excluded
assert not any("evil" in f["container_path"] for f in files)
def test_empty_when_no_skills_dir(self, tmp_path):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
assert iter_skills_files() == []
class TestPathTraversalSecurity:
"""Path traversal and absolute path rejection.
A malicious skill could declare::
required_credential_files:
- path: '../../.ssh/id_rsa'
Without containment checks, this would mount the host's SSH private key
into the container sandbox, leaking it to the skill's execution environment.
"""
def test_dotdot_traversal_rejected(self, tmp_path, monkeypatch):
"""'../sensitive' must not escape HERMES_HOME."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
(tmp_path / ".hermes").mkdir()
# Create a sensitive file one level above hermes_home
sensitive = tmp_path / "sensitive.json"
sensitive.write_text('{"secret": "value"}')
result = register_credential_file("../sensitive.json")
assert result is False
assert get_credential_file_mounts() == []
def test_deep_traversal_rejected(self, tmp_path, monkeypatch):
"""'../../etc/passwd' style traversal must be rejected."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Create a fake sensitive file outside hermes_home
ssh_dir = tmp_path / ".ssh"
ssh_dir.mkdir()
(ssh_dir / "id_rsa").write_text("PRIVATE KEY")
result = register_credential_file("../../.ssh/id_rsa")
assert result is False
assert get_credential_file_mounts() == []
def test_absolute_path_rejected(self, tmp_path, monkeypatch):
"""Absolute paths must be rejected regardless of whether they exist."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Create a file at an absolute path
sensitive = tmp_path / "absolute.json"
sensitive.write_text("{}")
result = register_credential_file(str(sensitive))
assert result is False
assert get_credential_file_mounts() == []
def test_legitimate_file_still_works(self, tmp_path, monkeypatch):
"""Normal files inside HERMES_HOME must still be registered."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "token.json").write_text('{"token": "abc"}')
result = register_credential_file("token.json")
assert result is True
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert "token.json" in mounts[0]["container_path"]
def test_nested_subdir_inside_hermes_home_allowed(self, tmp_path, monkeypatch):
"""Files in subdirectories of HERMES_HOME must be allowed."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
subdir = hermes_home / "creds"
subdir.mkdir()
(subdir / "oauth.json").write_text("{}")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
result = register_credential_file("creds/oauth.json")
assert result is True
def test_symlink_traversal_rejected(self, tmp_path, monkeypatch):
"""A symlink inside HERMES_HOME pointing outside must be rejected."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Create a sensitive file outside hermes_home
sensitive = tmp_path / "sensitive.json"
sensitive.write_text('{"secret": "value"}')
# Create a symlink inside hermes_home pointing outside
symlink = hermes_home / "evil_link.json"
try:
symlink.symlink_to(sensitive)
except (OSError, NotImplementedError):
pytest.skip("Symlinks not supported on this platform")
result = register_credential_file("evil_link.json")
# The resolved path escapes HERMES_HOME — must be rejected
assert result is False
assert get_credential_file_mounts() == []
# ---------------------------------------------------------------------------
# Config-based credential files — same containment checks
# ---------------------------------------------------------------------------
class TestConfigPathTraversal:
"""terminal.credential_files in config.yaml must also reject traversal."""
def _write_config(self, hermes_home: Path, cred_files: list):
import yaml
config_path = hermes_home / "config.yaml"
config_path.write_text(yaml.dump({"terminal": {"credential_files": cred_files}}))
def test_config_traversal_rejected(self, tmp_path, monkeypatch):
"""'../secret' in config.yaml must not escape HERMES_HOME."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
sensitive = tmp_path / "secret.json"
sensitive.write_text("{}")
self._write_config(hermes_home, ["../secret.json"])
mounts = get_credential_file_mounts()
host_paths = [m["host_path"] for m in mounts]
assert str(sensitive) not in host_paths
assert str(sensitive.resolve()) not in host_paths
def test_config_absolute_path_rejected(self, tmp_path, monkeypatch):
"""Absolute paths in config.yaml must be rejected."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
sensitive = tmp_path / "abs.json"
sensitive.write_text("{}")
self._write_config(hermes_home, [str(sensitive)])
mounts = get_credential_file_mounts()
assert mounts == []
def test_config_legitimate_file_works(self, tmp_path, monkeypatch):
"""Normal files inside HERMES_HOME via config must still mount."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "oauth.json").write_text("{}")
self._write_config(hermes_home, ["oauth.json"])
mounts = get_credential_file_mounts()
assert len(mounts) == 1
assert "oauth.json" in mounts[0]["container_path"]