From 5b92ed645997490e6912d947adcd71f78648c01e Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:23:00 +0530 Subject: [PATCH 1/4] security: confine repository context reads --- backend/app/main.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 359c165..0eda580 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -94,14 +94,29 @@ def clone_repo(repo_url: str) -> tuple[str, Path]: return workspace_id, workdir / "repo" +def _path_within_repo(repo: Path, candidate: Path) -> bool: + try: + candidate.resolve().relative_to(repo.resolve()) + except (OSError, ValueError): + return False + return True + + 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 not _path_within_repo(root, p): continue if p.is_file(): - results.append(str(p.relative_to(repo)).replace("\\", "/")) + results.append(str(rel).replace("\\", "/")) if len(results) >= limit: break return sorted(results) @@ -111,11 +126,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 = root / rel + if not _path_within_repo(root, candidate): + 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: From d75f0a40546749f9eedf19299902a97bd04decbb Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:23:02 +0530 Subject: [PATCH 2/4] test: cover repository context path confinement --- backend/tests/test_main.py | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) 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" From 5333af31a2ce7db04e46c17bf9d7f3a0bc5f6135 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:23:05 +0530 Subject: [PATCH 3/4] docs: document repository context confinement --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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. From 24f45957d5507dd28e93043255e38f7ac5717cc9 Mon Sep 17 00:00:00 2001 From: Harshit Sharma <66710144+harshitethic@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:23:31 +0530 Subject: [PATCH 4/4] security: read only validated resolved repository paths --- backend/app/main.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 0eda580..180c115 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -94,12 +94,14 @@ def clone_repo(repo_url: str) -> tuple[str, Path]: return workspace_id, workdir / "repo" -def _path_within_repo(repo: Path, candidate: Path) -> bool: +def _resolve_repo_path(repo: Path, candidate: Path) -> Path | None: + root = repo.resolve() try: - candidate.resolve().relative_to(repo.resolve()) + resolved = candidate.resolve() + resolved.relative_to(root) except (OSError, ValueError): - return False - return True + return None + return resolved def list_files(repo: Path, limit: int = 350) -> list[str]: @@ -113,9 +115,10 @@ def list_files(repo: Path, limit: int = 350) -> list[str]: continue if any(part in ignored for part in rel.parts): continue - if not _path_within_repo(root, p): + resolved = _resolve_repo_path(root, p) + if resolved is None: continue - if p.is_file(): + if resolved.is_file(): results.append(str(rel).replace("\\", "/")) if len(results) >= limit: break @@ -130,8 +133,8 @@ def read_repo_context(repo: Path, files: list[str], limit_chars: int = 65000) -> chunks: list[str] = [] total = 0 for rel in ordered[:100]: - candidate = root / rel - if not _path_within_repo(root, candidate): + candidate = _resolve_repo_path(root, root / rel) + if candidate is None: continue try: text = candidate.read_text(encoding="utf-8", errors="ignore")