diff --git a/CHANGELOG.md b/CHANGELOG.md index 0405b24..1aafe17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Hermes Web UI -- Changelog +## [v0.50.156] — 2026-04-22 + +### Security +- **⚠️ Breaking change — auto-install of agent dependencies is now opt-in** — users previously relying on auto-install must now set `HERMES_WEBUI_AUTO_INSTALL=1` to restore the previous behaviour. A new `_trusted_agent_dir()` check validates ownership and permission bits before allowing pip to run. (`api/startup.py`, `README.md`) (addresses #842 by @tomaioo) + ## [v0.50.155] — 2026-04-22 ### Fixed diff --git a/README.md b/README.md index a8d98ff..4c60d75 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ If discovery finds everything, nothing else is required. export HERMES_WEBUI_AGENT_DIR=/path/to/hermes-agent export HERMES_WEBUI_PYTHON=/path/to/python export HERMES_WEBUI_PORT=9000 +export HERMES_WEBUI_AUTO_INSTALL=1 # enable auto-install of agent deps (disabled by default) ./start.sh ``` diff --git a/api/startup.py b/api/startup.py index 0a66f5a..602bf75 100644 --- a/api/startup.py +++ b/api/startup.py @@ -41,11 +41,41 @@ def _agent_dir() -> Path | None: return p.resolve() return None +def _trusted_agent_dir(agent_dir: Path) -> bool: + """Return True if agent_dir passes ownership and permission checks. + + Validates that the directory is not world- or group-writable and, + on POSIX systems, is owned by the current process user. + + Intentionally does NOT enforce a canonical path (i.e. does not require + the dir to be ~/.hermes/hermes-agent), so custom HERMES_WEBUI_AGENT_DIR + paths work correctly when HERMES_WEBUI_AUTO_INSTALL=1 is set. + """ + try: + st = agent_dir.stat() + if stat.S_IMODE(st.st_mode) & 0o022: + # World- or group-writable — untrusted + return False + if hasattr(os, 'getuid') and st.st_uid != os.getuid(): + # Not owned by current user (POSIX only; Windows fallback skips) + return False + return True + except OSError: + return False + + def auto_install_agent_deps() -> bool: + enabled = os.environ.get('HERMES_WEBUI_AUTO_INSTALL', '').strip().lower() in ('1', 'true', 'yes') + if not enabled: + print('[!!] Auto-install disabled. Set HERMES_WEBUI_AUTO_INSTALL=1 to enable.', flush=True) + return False agent_dir = _agent_dir() if agent_dir is None: print('[!!] Auto-install skipped: agent directory not found.', flush=True) return False + if not _trusted_agent_dir(agent_dir): + print('[!!] Auto-install skipped: agent directory failed trust check (check ownership/permissions).', flush=True) + return False req_file = agent_dir / 'requirements.txt' pyproject = agent_dir / 'pyproject.toml' if req_file.exists(): diff --git a/tests/test_sprint32.py b/tests/test_sprint32.py index 8430ba3..c6ed92c 100644 --- a/tests/test_sprint32.py +++ b/tests/test_sprint32.py @@ -2,71 +2,117 @@ from pathlib import Path from unittest.mock import MagicMock, patch import subprocess import os -from api.startup import auto_install_agent_deps +from api.startup import auto_install_agent_deps, _trusted_agent_dir + class TestAutoInstallAgentDeps: + """Tests for auto_install_agent_deps(). + + All tests that exercise the install path set HERMES_WEBUI_AUTO_INSTALL=1 + (the new opt-in gate) and mock _trusted_agent_dir to return True (so pytest + tmp_path directories — which are group-writable by default — pass the check). + Tests that verify skip behavior set the flag and mock trust=True so they + reach the actual skip reason being tested. + """ + + def test_disabled_by_default(self, tmp_path, capsys): + """Auto-install must be off unless HERMES_WEBUI_AUTO_INSTALL=1 is set.""" + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + (agent_dir / 'requirements.txt').write_text('somepkg\n') + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)} + with patch.dict('os.environ', env, clear=False): + os.environ.pop('HERMES_WEBUI_AUTO_INSTALL', None) + with patch('subprocess.run') as mock_run: + assert auto_install_agent_deps() is False + assert not mock_run.called + assert 'disabled' in capsys.readouterr().out.lower() + def test_installs_from_requirements_txt(self, tmp_path): agent_dir = tmp_path / 'hermes-agent' agent_dir.mkdir() req = agent_dir / 'requirements.txt' req.write_text('pyyaml\n') - with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stderr='') - assert auto_install_agent_deps() is True - args = mock_run.call_args[0][0] - assert '-r' in args and str(req) in args + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=True): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + assert auto_install_agent_deps() is True + args = mock_run.call_args[0][0] + assert '-r' in args and str(req) in args def test_falls_back_to_pyproject(self, tmp_path): agent_dir = tmp_path / 'hermes-agent' agent_dir.mkdir() (agent_dir / 'pyproject.toml').write_text('[project]\nname="hermes-agent"\n') - with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=0, stderr='') - assert auto_install_agent_deps() is True - args = mock_run.call_args[0][0] - assert str(agent_dir) in args and '-r' not in args + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=True): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=0, stderr='') + assert auto_install_agent_deps() is True + args = mock_run.call_args[0][0] + assert str(agent_dir) in args and '-r' not in args def test_skips_when_agent_dir_missing(self, tmp_path, capsys): missing = tmp_path / 'nonexistent-agent' - # Patch both HERMES_WEBUI_AGENT_DIR and HERMES_HOME so the fallback - # path (HERMES_HOME/hermes-agent) also resolves to a nonexistent dir, - # preventing the real agent dir from being found in the test environment. env_overrides = { 'HERMES_WEBUI_AGENT_DIR': str(missing), 'HERMES_HOME': str(tmp_path / 'no-hermes-home'), + 'HERMES_WEBUI_AUTO_INSTALL': '1', } with patch.dict('os.environ', env_overrides, clear=False): with patch('subprocess.run') as mock_run: assert auto_install_agent_deps() is False assert not mock_run.called - assert 'skipped' in capsys.readouterr().out.lower() + out = capsys.readouterr().out.lower() + assert 'skipped' in out or 'not found' in out def test_skips_when_no_install_file(self, tmp_path, capsys): agent_dir = tmp_path / 'hermes-agent' agent_dir.mkdir() - with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): - with patch('subprocess.run') as mock_run: - assert auto_install_agent_deps() is False - assert not mock_run.called + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=True): + with patch('subprocess.run') as mock_run: + assert auto_install_agent_deps() is False + assert not mock_run.called assert 'skipped' in capsys.readouterr().out.lower() + def test_skips_when_dir_not_trusted(self, tmp_path, capsys): + """_trusted_agent_dir returning False must block installation.""" + agent_dir = tmp_path / 'hermes-agent' + agent_dir.mkdir() + (agent_dir / 'requirements.txt').write_text('somepkg\n') + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=False): + with patch('subprocess.run') as mock_run: + assert auto_install_agent_deps() is False + assert not mock_run.called + assert 'trust' in capsys.readouterr().out.lower() + def test_tolerates_pip_failure(self, tmp_path, capsys): agent_dir = tmp_path / 'hermes-agent' agent_dir.mkdir() (agent_dir / 'requirements.txt').write_text('somepkg\n') - with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): - with patch('subprocess.run') as mock_run: - mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package') - assert auto_install_agent_deps() is False - assert 'failed' in capsys.readouterr().out.lower() or 'pip' in capsys.readouterr().out.lower() + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=True): + with patch('subprocess.run') as mock_run: + mock_run.return_value = MagicMock(returncode=1, stderr='ERROR: could not find package') + assert auto_install_agent_deps() is False + out = capsys.readouterr().out.lower() + assert 'failed' in out or 'pip' in out def test_tolerates_timeout(self, tmp_path, capsys): agent_dir = tmp_path / 'hermes-agent' agent_dir.mkdir() (agent_dir / 'requirements.txt').write_text('somepkg\n') - with patch.dict('os.environ', {'HERMES_WEBUI_AGENT_DIR': str(agent_dir)}, clear=False): - with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)): - assert auto_install_agent_deps() is False + env = {'HERMES_WEBUI_AGENT_DIR': str(agent_dir), 'HERMES_WEBUI_AUTO_INSTALL': '1'} + with patch.dict('os.environ', env, clear=False): + with patch('api.startup._trusted_agent_dir', return_value=True): + with patch('subprocess.run', side_effect=subprocess.TimeoutExpired('pip', 120)): + assert auto_install_agent_deps() is False assert 'timed out' in capsys.readouterr().out.lower()