diff --git a/README.md b/README.md index 5fcd3a5..2c6ccde 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,8 @@ The agent can clone repositories and execute detected project commands inside a - secret isolation - explicit human approval before write/push/PR actions +Repository context collection is confined to files whose resolved paths stay inside the cloned repository. Paths or symlinks that resolve outside the clone are excluded before file contents are sent to the configured model. + GitHub issue import also introduces a credential boundary: keep `GITHUB_TOKEN` server-side and use the minimum permissions required. The project roadmap intentionally includes a stronger sandbox for this reason. diff --git a/backend/app/main.py b/backend/app/main.py index 359c165..180c115 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -94,14 +94,32 @@ def clone_repo(repo_url: str) -> tuple[str, Path]: return workspace_id, workdir / "repo" +def _resolve_repo_path(repo: Path, candidate: Path) -> Path | None: + root = repo.resolve() + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + return None + return resolved + + def list_files(repo: Path, limit: int = 350) -> list[str]: ignored = {".git", "node_modules", ".venv", "venv", "dist", "build", "__pycache__", ".next", "coverage", ".cache"} + root = repo.resolve() results: list[str] = [] for p in repo.rglob("*"): - if any(part in ignored for part in p.parts): + try: + rel = p.relative_to(repo) + except ValueError: + continue + if any(part in ignored for part in rel.parts): continue - if p.is_file(): - results.append(str(p.relative_to(repo)).replace("\\", "/")) + resolved = _resolve_repo_path(root, p) + if resolved is None: + continue + if resolved.is_file(): + results.append(str(rel).replace("\\", "/")) if len(results) >= limit: break return sorted(results) @@ -111,11 +129,15 @@ def read_repo_context(repo: Path, files: list[str], limit_chars: int = 65000) -> preferred_ext = {".py", ".js", ".jsx", ".ts", ".tsx", ".go", ".rs", ".java", ".kt", ".rb", ".php", ".json", ".yml", ".yaml", ".toml", ".md", ".sql"} preferred_names = {"package.json", "pyproject.toml", "requirements.txt", "README.md", "go.mod", "Cargo.toml"} ordered = sorted(files, key=lambda f: (Path(f).name not in preferred_names, Path(f).suffix not in preferred_ext, len(f))) + root = repo.resolve() chunks: list[str] = [] total = 0 for rel in ordered[:100]: + candidate = _resolve_repo_path(root, root / rel) + if candidate is None: + continue try: - text = (repo / rel).read_text(encoding="utf-8", errors="ignore") + text = candidate.read_text(encoding="utf-8", errors="ignore") except OSError: continue if len(text) > 7000: diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index c20cdb5..e9e5d0c 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -13,7 +13,9 @@ apply_edits, github_headers, health, + list_files, parse_json_object, + read_repo_context, safe_branch_name, safe_repo_name, workspace_repo, @@ -101,6 +103,64 @@ def test_apply_edits_is_atomic_when_later_edit_is_invalid(self) -> None: self.assertEqual(first.read_text(encoding="utf-8"), "alpha") self.assertEqual(second.read_text(encoding="utf-8"), "beta") + def test_list_files_excludes_symlink_that_resolves_outside_repo(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + repo = root / "repo" + repo.mkdir() + outside = root / "outside-secret.txt" + outside.write_text("TOP SECRET", encoding="utf-8") + link = repo / "linked-secret.txt" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available in this environment") + + self.assertNotIn("linked-secret.txt", list_files(repo)) + + def test_read_repo_context_rejects_parent_path_escape(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + repo = root / "repo" + repo.mkdir() + outside = root / "secret.txt" + outside.write_text("DO NOT EXPOSE", encoding="utf-8") + + context = read_repo_context(repo, ["../secret.txt"]) + + self.assertEqual(context, "") + self.assertNotIn("DO NOT EXPOSE", context) + + def test_read_repo_context_rejects_outside_symlink_even_if_supplied_directly(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + repo = root / "repo" + repo.mkdir() + outside = root / "secret.txt" + outside.write_text("DO NOT EXPOSE", encoding="utf-8") + link = repo / "secret-link.txt" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available in this environment") + + context = read_repo_context(repo, ["secret-link.txt"]) + + self.assertEqual(context, "") + self.assertNotIn("DO NOT EXPOSE", context) + + def test_read_repo_context_still_reads_normal_repository_files(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo = Path(temp_dir) + source = repo / "src" / "example.py" + source.parent.mkdir() + source.write_text("print('safe')\n", encoding="utf-8") + + context = read_repo_context(repo, ["src/example.py"]) + + self.assertIn("### FILE: src/example.py", context) + self.assertIn("print('safe')", context) + def test_workspace_repo_rejects_path_traversal(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "workspaces"