|
22 | 22 | from typing import Optional |
23 | 23 |
|
24 | 24 | from adk_documentation.settings import GITHUB_BASE_URL |
| 25 | +from adk_documentation.settings import LOCAL_REPOS_DIR_PATH |
25 | 26 | from adk_documentation.utils import error_response |
26 | 27 | from adk_documentation.utils import get_paginated_request |
27 | 28 | from adk_documentation.utils import get_request |
|
30 | 31 | import requests |
31 | 32 |
|
32 | 33 |
|
| 34 | +def _resolve_within_repos_dir(path: str) -> Optional[str]: |
| 35 | + """Resolves `path` and verifies it stays within LOCAL_REPOS_DIR_PATH. |
| 36 | +
|
| 37 | + Returns the resolved absolute path if it is inside the sandbox, else None. |
| 38 | + """ |
| 39 | + allowed_root = os.path.realpath(LOCAL_REPOS_DIR_PATH) |
| 40 | + resolved = os.path.realpath(path) |
| 41 | + if resolved == allowed_root or resolved.startswith(allowed_root + os.sep): |
| 42 | + return resolved |
| 43 | + return None |
| 44 | + |
| 45 | + |
| 46 | +def _confine_path( |
| 47 | + path: str, param_name: str |
| 48 | +) -> tuple[Optional[str], Optional[Dict[str, Any]]]: |
| 49 | + """Resolves `path` and verifies it stays within LOCAL_REPOS_DIR_PATH. |
| 50 | +
|
| 51 | + The file/search tools are only meant to operate on the repositories cloned |
| 52 | + under LOCAL_REPOS_DIR_PATH. Because these tools are driven by an LLM that |
| 53 | + processes untrusted input (issue bodies, release diffs), an `os.path.isabs` |
| 54 | + check alone lets a crafted instruction read or write arbitrary paths (e.g. |
| 55 | + the credentials file pointed to by GOOGLE_APPLICATION_CREDENTIALS, or |
| 56 | + /proc/self/environ). Resolve symlinks and `..` segments first, then require |
| 57 | + the result to stay inside the managed directory. |
| 58 | +
|
| 59 | + Returns: |
| 60 | + A tuple of (safe_path, error_response_dict). If the path is outside the |
| 61 | + sandbox, safe_path is None and error_response_dict contains the error. |
| 62 | + """ |
| 63 | + safe_path = _resolve_within_repos_dir(path) |
| 64 | + if safe_path is None: |
| 65 | + return None, error_response( |
| 66 | + f"Access denied: {param_name} is outside the managed repositories " |
| 67 | + f"directory ({LOCAL_REPOS_DIR_PATH}): {path}" |
| 68 | + ) |
| 69 | + return safe_path, None |
| 70 | + |
| 71 | + |
33 | 72 | def list_releases(repo_owner: str, repo_name: str) -> Dict[str, Any]: |
34 | 73 | """Lists all releases for a repository. |
35 | 74 |
|
@@ -135,6 +174,10 @@ def clone_or_pull_repo( |
135 | 174 | A dictionary indicating the status of the operation, output message, and |
136 | 175 | the head commit hash. |
137 | 176 | """ |
| 177 | + local_path, err = _confine_path(local_path, "local_path") |
| 178 | + if err: |
| 179 | + return err |
| 180 | + |
138 | 181 | repo_url = f"git@github.com:{repo_owner}/{repo_name}.git" |
139 | 182 |
|
140 | 183 | try: |
@@ -188,6 +231,10 @@ def read_local_git_repo_file_content(file_path: str) -> Dict[str, Any]: |
188 | 231 | f"file_path must be an absolute path, got: {file_path}" |
189 | 232 | ) |
190 | 233 |
|
| 234 | + file_path, err = _confine_path(file_path, "file_path") |
| 235 | + if err: |
| 236 | + return err |
| 237 | + |
191 | 238 | try: |
192 | 239 | dir_path = os.path.dirname(file_path) |
193 | 240 | head_commit_sha = _find_head_commit_sha(dir_path) |
@@ -229,6 +276,9 @@ def list_directory_contents(directory_path: str) -> Dict[str, Any]: |
229 | 276 | print( |
230 | 277 | f"Attempting to recursively list contents of directory: {directory_path}" |
231 | 278 | ) |
| 279 | + directory_path, err = _confine_path(directory_path, "directory_path") |
| 280 | + if err: |
| 281 | + return err |
232 | 282 | if not os.path.isdir(directory_path): |
233 | 283 | return error_response(f"Error: Directory not found at {directory_path}") |
234 | 284 |
|
@@ -276,6 +326,9 @@ def search_local_git_repo( |
276 | 326 | f"Attempting to search for pattern: {pattern} in directory:" |
277 | 327 | f" {directory_path}, with extensions: {extensions}" |
278 | 328 | ) |
| 329 | + directory_path, err = _confine_path(directory_path, "directory_path") |
| 330 | + if err: |
| 331 | + return err |
279 | 332 | try: |
280 | 333 | grep_process = _git_grep(directory_path, pattern, extensions, ignored_dirs) |
281 | 334 | if grep_process.returncode > 1: |
@@ -350,8 +403,18 @@ def create_pull_request_from_changes( |
350 | 403 | if not changes: |
351 | 404 | return error_response("No changes provided to apply.") |
352 | 405 |
|
| 406 | + repo_root = os.path.realpath(local_path) |
353 | 407 | for relative_path, new_content in changes.items(): |
354 | | - full_path = os.path.join(local_path, relative_path) |
| 408 | + full_path = os.path.realpath(os.path.join(local_path, relative_path)) |
| 409 | + # Confine writes to the repository: a crafted relative_path such as |
| 410 | + # "../../etc/x" would otherwise escape `local_path`. |
| 411 | + if not ( |
| 412 | + full_path == repo_root or full_path.startswith(repo_root + os.sep) |
| 413 | + ): |
| 414 | + return error_response( |
| 415 | + "Access denied: change path escapes the repository:" |
| 416 | + f" {relative_path}" |
| 417 | + ) |
355 | 418 | os.makedirs(os.path.dirname(full_path), exist_ok=True) |
356 | 419 | with open(full_path, "w", encoding="utf-8") as f: |
357 | 420 | f.write(new_content) |
|
0 commit comments