From c3efed144ea7940eeb84b8a17aad32f1668f5f85 Mon Sep 17 00:00:00 2001 From: JingWen Fan <106414602+study8677@users.noreply.github.com> Date: Wed, 20 May 2026 17:57:30 +0800 Subject: [PATCH] fix(hub): prevent project-context symlink exfiltration --- engine/antigravity_engine/hub/ask_pipeline.py | 39 +++++++++++++------ engine/tests/test_hub_pipeline.py | 14 +++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/engine/antigravity_engine/hub/ask_pipeline.py b/engine/antigravity_engine/hub/ask_pipeline.py index 1bb720f3e..b1fccf414 100644 --- a/engine/antigravity_engine/hub/ask_pipeline.py +++ b/engine/antigravity_engine/hub/ask_pipeline.py @@ -15,6 +15,7 @@ from pathlib import Path from antigravity_engine.hub._constants import SKIP_DIRS +from antigravity_engine.hub._utils import is_safe_path from antigravity_engine.hub.contracts import ( ClaimVerification, ModuleClaim, @@ -486,6 +487,7 @@ def _load_project_context( per_source_cap = max(1000, max_chars // 3) budget = max_chars parts: list[str] = [] + workspace = ag_dir.parent def _push(label: str, content: str) -> None: nonlocal budget @@ -495,6 +497,25 @@ def _push(label: str, content: str) -> None: parts.append(f"### {label}\n{chunk}") budget -= len(chunk) + def _read_project_doc(path: Path) -> str: + """Read a bounded project doc only when it resolves inside workspace.""" + if budget <= 0: + return "" + limit = min(per_source_cap, budget) + try: + resolved = path.resolve() + except OSError: + return "" + if not is_safe_path(workspace, resolved): + return "" + try: + if not resolved.is_file(): + return "" + with resolved.open("r", encoding="utf-8", errors="replace") as f: + return f.read(limit) + except OSError: + return "" + file_sources: list[tuple[str, Path]] = [ ("Project Conventions (.antigravity/conventions.md)", ag_dir / "conventions.md"), ("Document Index (.antigravity/document_index.md)", ag_dir / "document_index.md"), @@ -502,12 +523,9 @@ def _push(label: str, content: str) -> None: for label, path in file_sources: if budget <= 0: break - if not path.is_file(): - continue - try: - _push(label, path.read_text(encoding="utf-8")) - except OSError: - continue + content = _read_project_doc(path) + if content: + _push(label, content) if budget > 0 and map_content.strip(): _push("Module Map (.antigravity/map.md)", map_content) @@ -519,12 +537,9 @@ def _push(label: str, content: str) -> None: for label, path in file_sources_after_map: if budget <= 0: break - if not path.is_file(): - continue - try: - _push(label, path.read_text(encoding="utf-8")) - except OSError: - continue + content = _read_project_doc(path) + if content: + _push(label, content) if not parts: return "" diff --git a/engine/tests/test_hub_pipeline.py b/engine/tests/test_hub_pipeline.py index b1f4ce616..9639a4af1 100644 --- a/engine/tests/test_hub_pipeline.py +++ b/engine/tests/test_hub_pipeline.py @@ -261,6 +261,20 @@ def test_load_project_context_respects_total_budget(tmp_path: Path) -> None: assert "REGISTRY_MARKER" in section +def test_load_project_context_skips_symlink_outside_workspace(tmp_path: Path) -> None: + """Symlinked project docs resolving outside workspace must be ignored.""" + from antigravity_engine.hub.ask_pipeline import _load_project_context + + ag_dir = tmp_path / ".antigravity" + ag_dir.mkdir() + outside = tmp_path.parent / "outside-secret.txt" + outside.write_text("SECRET_TOKEN_OUTSIDE\n", encoding="utf-8") + (ag_dir / "conventions.md").symlink_to(outside) + + section = _load_project_context(ag_dir, map_content="") + assert section == "" + + # --------------------------------------------------------------------------- # Phase 1: config/entry/git in _format_scan_report # ---------------------------------------------------------------------------