From eb51fb6f501efc97ba026807f49517d326c4105f Mon Sep 17 00:00:00 2001 From: Stark-X Date: Mon, 11 May 2026 16:39:52 +0800 Subject: [PATCH] fix(ssh): keep bulk sync extraction scoped to .hermes --- tests/tools/test_ssh_bulk_upload.py | 38 ++++++++++++++++++++++++----- tools/environments/ssh.py | 17 +++++++++++-- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_ssh_bulk_upload.py b/tests/tools/test_ssh_bulk_upload.py index cbdb6543495..afad54cf4f4 100644 --- a/tests/tools/test_ssh_bulk_upload.py +++ b/tests/tools/test_ssh_bulk_upload.py @@ -91,7 +91,7 @@ class TestSSHBulkUpload: assert "/home/testuser/.hermes/credentials" in mkdir_str def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path): - """Symlinks in staging dir should mirror the remote path structure.""" + """Symlinks in staging dir should mirror the .hermes-relative layout.""" f1 = tmp_path / "local_a.txt" f1.write_text("content a") @@ -107,9 +107,7 @@ class TestSSHBulkUpload: c_idx = cmd.index("-C") staging_dir = cmd[c_idx + 1] # Check the symlink exists - expected = os.path.join( - staging_dir, "home/testuser/.hermes/skills/my_skill.md" - ) + expected = os.path.join(staging_dir, "skills/my_skill.md") staging_paths.append(expected) assert os.path.islink(expected), f"Expected symlink at {expected}" assert os.readlink(expected) == os.path.abspath(str(f1)) @@ -166,14 +164,42 @@ class TestSSHBulkUpload: assert "-" in tar_cmd # stdout assert "-C" in tar_cmd - # ssh: extract from stdin at /, preserving existing dir modes (#17767) + # ssh: extract from stdin at ~/.hermes, preserving existing dir modes (#17767) ssh_str = " ".join(ssh_cmd) assert "ssh" in ssh_str assert "tar xf -" in ssh_str assert "--no-overwrite-dir" in ssh_str - assert "-C /" in ssh_str + assert "-C /home/testuser/.hermes" in ssh_str assert "testuser@example.com" in ssh_str + def test_bulk_upload_never_stages_remote_home_prefix(self, mock_env, tmp_path): + """Regression: do not archive /home/ path components.""" + f1 = tmp_path / "nested.txt" + f1.write_text("nested") + files = [(str(f1), "/home/testuser/.hermes/cache/nested.txt")] + + def capture_tar_cmd(cmd, **kwargs): + if cmd[0] == "tar": + c_idx = cmd.index("-C") + staging_dir = cmd[c_idx + 1] + assert not os.path.exists(os.path.join(staging_dir, "home")) + expected = os.path.join(staging_dir, "cache/nested.txt") + assert os.path.islink(expected) + + mock = MagicMock() + mock.stdout = MagicMock() + mock.returncode = 0 + mock.poll.return_value = 0 + mock.communicate.return_value = (b"", b"") + mock.stderr = MagicMock() + mock.stderr.read.return_value = b"" + return mock + + with patch.object(subprocess, "run", + return_value=subprocess.CompletedProcess([], 0)), \ + patch.object(subprocess, "Popen", side_effect=capture_tar_cmd): + mock_env._ssh_bulk_upload(files) + def test_mkdir_failure_raises(self, mock_env, tmp_path): """mkdir failure should raise RuntimeError before tar pipe.""" f1 = tmp_path / "y.txt" diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 1f1afb48440..8924d76895f 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -169,6 +169,7 @@ class SSHEnvironment(BaseEnvironment): if not files: return + base = f"{self._remote_home}/.hermes" parents = unique_parent_dirs(files) if parents: cmd = self._build_ssh_command() @@ -180,7 +181,19 @@ class SSHEnvironment(BaseEnvironment): # Symlink staging avoids fragile GNU tar --transform rules. with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging: for host_path, remote_path in files: - staged = os.path.join(staging, remote_path.lstrip("/")) + try: + rel_remote = os.path.relpath(remote_path, base) + except ValueError as exc: + raise RuntimeError( + f"remote path {remote_path!r} is not under sync base {base!r}" + ) from exc + + if rel_remote == "." or rel_remote.startswith("../"): + raise RuntimeError( + f"remote path {remote_path!r} escapes sync base {base!r}" + ) + + staged = os.path.join(staging, rel_remote) os.makedirs(os.path.dirname(staged), exist_ok=True) os.symlink(os.path.abspath(host_path), staged) @@ -190,7 +203,7 @@ class SSHEnvironment(BaseEnvironment): # existing directories (e.g. /home/) with the staging # directory's mode. Without this, a umask 002 produces 0775 # dirs which breaks sshd StrictModes (refuses authorized_keys). - ssh_cmd.append("tar xf - --no-overwrite-dir -C /") + ssh_cmd.append(f"tar xf - --no-overwrite-dir -C {shlex.quote(base)}") tar_proc = subprocess.Popen( tar_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE