Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 26 additions & 4 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions backend/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
Loading