@@ -197,3 +197,110 @@ def test_empty_when_no_skills_dir(self, tmp_path):
197197
198198 with patch .dict (os .environ , {"HERMES_HOME" : str (hermes_home )}):
199199 assert iter_skills_files () == []
200+
201+ class TestPathTraversalSecurity :
202+ """Path traversal and absolute path rejection.
203+
204+ A malicious skill could declare::
205+
206+ required_credential_files:
207+ - path: '../../.ssh/id_rsa'
208+
209+ Without containment checks, this would mount the host's SSH private key
210+ into the container sandbox, leaking it to the skill's execution environment.
211+ """
212+
213+ def test_dotdot_traversal_rejected (self , tmp_path , monkeypatch ):
214+ """'../sensitive' must not escape HERMES_HOME."""
215+ monkeypatch .setenv ("HERMES_HOME" , str (tmp_path / ".hermes" ))
216+ (tmp_path / ".hermes" ).mkdir ()
217+
218+ # Create a sensitive file one level above hermes_home
219+ sensitive = tmp_path / "sensitive.json"
220+ sensitive .write_text ('{"secret": "value"}' )
221+
222+ result = register_credential_file ("../sensitive.json" )
223+
224+ assert result is False
225+ assert get_credential_file_mounts () == []
226+
227+ def test_deep_traversal_rejected (self , tmp_path , monkeypatch ):
228+ """'../../etc/passwd' style traversal must be rejected."""
229+ hermes_home = tmp_path / ".hermes"
230+ hermes_home .mkdir ()
231+ monkeypatch .setenv ("HERMES_HOME" , str (hermes_home ))
232+
233+ # Create a fake sensitive file outside hermes_home
234+ ssh_dir = tmp_path / ".ssh"
235+ ssh_dir .mkdir ()
236+ (ssh_dir / "id_rsa" ).write_text ("PRIVATE KEY" )
237+
238+ result = register_credential_file ("../../.ssh/id_rsa" )
239+
240+ assert result is False
241+ assert get_credential_file_mounts () == []
242+
243+ def test_absolute_path_rejected (self , tmp_path , monkeypatch ):
244+ """Absolute paths must be rejected regardless of whether they exist."""
245+ hermes_home = tmp_path / ".hermes"
246+ hermes_home .mkdir ()
247+ monkeypatch .setenv ("HERMES_HOME" , str (hermes_home ))
248+
249+ # Create a file at an absolute path
250+ sensitive = tmp_path / "absolute.json"
251+ sensitive .write_text ("{}" )
252+
253+ result = register_credential_file (str (sensitive ))
254+
255+ assert result is False
256+ assert get_credential_file_mounts () == []
257+
258+ def test_legitimate_file_still_works (self , tmp_path , monkeypatch ):
259+ """Normal files inside HERMES_HOME must still be registered."""
260+ hermes_home = tmp_path / ".hermes"
261+ hermes_home .mkdir ()
262+ monkeypatch .setenv ("HERMES_HOME" , str (hermes_home ))
263+ (hermes_home / "token.json" ).write_text ('{"token": "abc"}' )
264+
265+ result = register_credential_file ("token.json" )
266+
267+ assert result is True
268+ mounts = get_credential_file_mounts ()
269+ assert len (mounts ) == 1
270+ assert "token.json" in mounts [0 ]["container_path" ]
271+
272+ def test_nested_subdir_inside_hermes_home_allowed (self , tmp_path , monkeypatch ):
273+ """Files in subdirectories of HERMES_HOME must be allowed."""
274+ hermes_home = tmp_path / ".hermes"
275+ hermes_home .mkdir ()
276+ subdir = hermes_home / "creds"
277+ subdir .mkdir ()
278+ (subdir / "oauth.json" ).write_text ("{}" )
279+ monkeypatch .setenv ("HERMES_HOME" , str (hermes_home ))
280+
281+ result = register_credential_file ("creds/oauth.json" )
282+
283+ assert result is True
284+
285+ def test_symlink_traversal_rejected (self , tmp_path , monkeypatch ):
286+ """A symlink inside HERMES_HOME pointing outside must be rejected."""
287+ hermes_home = tmp_path / ".hermes"
288+ hermes_home .mkdir ()
289+ monkeypatch .setenv ("HERMES_HOME" , str (hermes_home ))
290+
291+ # Create a sensitive file outside hermes_home
292+ sensitive = tmp_path / "sensitive.json"
293+ sensitive .write_text ('{"secret": "value"}' )
294+
295+ # Create a symlink inside hermes_home pointing outside
296+ symlink = hermes_home / "evil_link.json"
297+ try :
298+ symlink .symlink_to (sensitive )
299+ except (OSError , NotImplementedError ):
300+ pytest .skip ("Symlinks not supported on this platform" )
301+
302+ result = register_credential_file ("evil_link.json" )
303+
304+ # The resolved path escapes HERMES_HOME — must be rejected
305+ assert result is False
306+ assert get_credential_file_mounts () == []
0 commit comments