diff --git a/agent/context_references.py b/agent/context_references.py index d574d8619d..8677eb44d2 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -459,6 +459,27 @@ def _composer_paste_roots() -> list[Path]: return [hermes_dir / COMPOSER_PASTES_DIRNAME for hermes_dir in _hermes_dirs()] +def _agent_staged_path(path: Path) -> bool: + """True when *path* sits in a Hermes dir the gateway stages for the agent. + + Those are the ``_CACHE_DIRS`` roots — ``attachments/`` (file drops), + ``images/`` (image uploads), ``cache/*`` (platform downloads) and now + ``composer-pastes/`` (large text pastes) — the gateway's OWN payload, never + a workspace escape, and they live outside the workspace by construction: on + a remote execution backend (ssh and friends) the workspace root is a path + on THAT host (#110174), so the workspace check below rejected every staged + attachment there. The bytes are staged to (or already live on) the gateway + either way, so the ref still expands — text inlines; binaries point at the + backend-visible path via ``to_agent_visible_cache_path``. + """ + try: + from tools.credential_files import get_cache_directory_mounts + return any(_is_under(path, Path(entry["host_path"]).expanduser().resolve()) + for entry in get_cache_directory_mounts()) + except Exception: + return False + + def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) -> Path: from agent.file_safety import is_nt_namespace_path if is_nt_namespace_path(target): # raw-string check: resolving such a path is the NTLM-leak trigger @@ -468,6 +489,7 @@ def _resolve_path(cwd: Path, target: str, *, allowed_root: Path | None = None) - allowed_root is not None and not _is_under(resolved, allowed_root) and not any(_is_under(resolved, root) for root in _composer_paste_roots()) + and not _agent_staged_path(resolved) ): raise ValueError("path is outside the allowed workspace") return resolved diff --git a/tests/agent/test_context_references.py b/tests/agent/test_context_references.py index 7347f07fdf..8dd5e728f0 100644 --- a/tests/agent/test_context_references.py +++ b/tests/agent/test_context_references.py @@ -460,6 +460,106 @@ def test_binary_reference_block_keeps_host_path_on_local_backend(tmp_path: Path, assert "/root/.hermes/attachments/" not in result.message +# ── Gateway container + Remote SSH execution backend (#110174) ─────────────── +# ``file.attach`` stages pastes/drops into the session home — the GATEWAY's own +# filesystem — while the workspace root (TERMINAL_CWD) is a path on the SSH host. +# The two filesystems are disjoint, so the workspace check used to reject every +# staged attachment ("path is outside the allowed workspace") and the agent never +# saw the content even though the gateway held the bytes. + +_SSH_WORKSPACE = "/srv/repos" # the SSH host's workspace root; absent on the gateway + + +def _stage_outside_workspace(tmp_path: Path, monkeypatch, name: str, subdir: str = "attachments") -> Path: + """Stage *name* under a HERMES_HOME that lives outside the workspace (SSH topology).""" + hermes_home = tmp_path / "gateway-data" # /opt/data inside the gateway container + (hermes_home / subdir).mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + return hermes_home / subdir / name + + +def test_ssh_backend_staged_text_attachment_still_expands(tmp_path: Path, monkeypatch): + """The gateway owns the bytes, so the ref must inline them, not refuse the path.""" + from agent.context_references import format_reference_value, preprocess_context_references + + payload = _stage_outside_workspace( + tmp_path, monkeypatch, "Pasted content (3-5.4 KB).txt", subdir="composer-pastes") + payload.write_text("pasted body\n", encoding="utf-8") + + result = preprocess_context_references( + f"Summarize @file:{format_reference_value(str(payload))}", + cwd=_SSH_WORKSPACE, + allowed_root=_SSH_WORKSPACE, + context_length=100_000, + ) + + assert result.expanded + assert result.warnings == [] + assert "pasted body" in result.message + + +def test_ssh_backend_staged_binary_attachment_points_at_the_synced_remote_path(tmp_path: Path, monkeypatch): + """Binaries stay on disk: the ref must render the path the ssh backend resolves (the + file-sync mirrors the staging dirs to ``~/.hermes`` on the remote).""" + from agent.context_references import preprocess_context_references + + payload = _stage_outside_workspace(tmp_path, monkeypatch, "archive.zip") + payload.write_bytes(b"PK\x03\x04binary-zip-bytes") + + result = preprocess_context_references( + f"Read the attachment @file:{payload}", + cwd=_SSH_WORKSPACE, + allowed_root=_SSH_WORKSPACE, + context_length=100_000, + ) + + assert result.expanded + assert "binary file, not inlined" in result.message + assert "~/.hermes/attachments/archive.zip" in result.message + + +def test_ref_outside_workspace_and_staging_dirs_is_still_refused(tmp_path: Path, monkeypatch): + """Staging dirs are the only widening: any other path outside the workspace stays blocked.""" + from agent.context_references import preprocess_context_references + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "gateway-data")) + monkeypatch.setenv("TERMINAL_ENV", "ssh") + secret = tmp_path / "elsewhere" / "notes.txt" + secret.parent.mkdir(parents=True) + secret.write_text("not-for-the-agent\n", encoding="utf-8") + + result = preprocess_context_references( + f"@file:{secret}", + cwd=_SSH_WORKSPACE, + allowed_root=_SSH_WORKSPACE, + context_length=100_000, + ) + + assert any("outside the allowed workspace" in warning for warning in result.warnings) + assert "not-for-the-agent" not in result.message + + +def test_local_backend_staged_attachment_keeps_the_host_path(tmp_path: Path, monkeypatch): + """Local backend: gateway and tools share a filesystem — no remote path rewrite.""" + from agent.context_references import preprocess_context_references + + payload = _stage_outside_workspace(tmp_path, monkeypatch, "archive.zip") + payload.write_bytes(b"PK\x03\x04binary-zip-bytes") + monkeypatch.setenv("TERMINAL_ENV", "local") + + result = preprocess_context_references( + f"Read the attachment @file:{payload}", + cwd=tmp_path / "workspace", + allowed_root=tmp_path / "workspace", + context_length=100_000, + ) + + assert result.expanded + assert "binary file, not inlined" in result.message + assert "~/.hermes/attachments/" not in result.message + + diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index 72490074df..26928f18eb 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -443,6 +443,30 @@ class TestCacheDirectoryMounts: for mount in mounts: assert Path(mount["host_path"]).is_dir() + def test_composer_pastes_mounts_and_syncs(self, tmp_path, monkeypatch): + """``composer-pastes/`` joins the staging dirs (#110174). + + Desktop stages a large paste there and attaches it as ``@file:``; on a + remote execution backend (ssh/daytona/vercel_sandbox) the bytes only + reach the agent through the file-sync enumeration, and the agent-visible + path translation only covers mounted dirs — so the dir must appear in + BOTH the mounts and the sync list, or a fresh paste dangles on the + remote host.""" + from tools.environments.file_sync import iter_sync_files + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + paste = hermes_home / "composer-pastes" / "pasted_content_20260924_x.txt" + paste.parent.mkdir() + paste.write_text("pasted body", encoding="utf-8") + + mounts = get_cache_directory_mounts() + assert "/root/.hermes/composer-pastes" in {m["container_path"] for m in mounts} + + synced = {Path(host) for host, _ in iter_sync_files("~/.hermes")} + assert paste in synced + def test_images_upload_file_maps_into_container(self, tmp_path, monkeypatch): """A concrete upload under ``images/`` maps to its container path. diff --git a/tools/credential_files.py b/tools/credential_files.py index b93070e1bc..fc9881b8d1 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -258,6 +258,13 @@ _CACHE_DIRS: list[tuple[str, str]] = [ # Mount it so the agent's file tools can read dropped binaries (zip/pdf/...) from inside sandbox # containers instead of dangling host paths (#76577). ("attachments", "attachments"), + # Desktop stages a large plain-text paste as a `.txt` under this Hermes-managed dir + # (apps/desktop/electron/composer-paste.ts; `COMPOSER_PASTES_DIRNAME` in + # agent/context_references.py) and attaches it as `@file:`. Without a mount/sync + # entry, remote execution backends (ssh/daytona/vercel_sandbox) never received the + # bytes and `to_agent_visible_cache_path` left the gateway-host path dangling on + # the remote host (#110174). No legacy alias, so both tuple slots match. + ("composer-pastes", "composer-pastes"), ]