-
Notifications
You must be signed in to change notification settings - Fork 758
fix(file tools) implement directory restrictions to file tools #1496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
0302449
773a54d
2d1b7af
8fe0b87
e34e54e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,33 @@ | |
| # --- File Operation Tools --- | ||
|
|
||
|
|
||
| def _validate_path( | ||
| file_path: Path, | ||
| allowed_roots: Path | list[Path] | None, | ||
| ) -> Path: | ||
| """Resolve the path and ensure it is within at least one allowed root directory.""" | ||
| resolved = file_path.resolve() | ||
| if allowed_roots is None: | ||
| return resolved | ||
|
|
||
| roots = [allowed_roots] if isinstance(allowed_roots, Path) else allowed_roots | ||
| for root in roots: | ||
| if resolved.is_relative_to(root.resolve()): | ||
| return resolved | ||
|
|
||
| root_list = ", ".join(str(r.resolve()) for r in roots) | ||
| raise PermissionError(f"Access denied: {resolved} is outside the allowed directories: {root_list}") | ||
|
|
||
|
|
||
| def read_file_content( | ||
| file_path: Path, | ||
| offset: int | None = None, | ||
| limit: int | None = None, | ||
| allowed_root: Path | list[Path] | None = None, | ||
| ) -> str: | ||
| """Reads a file with line numbers, raising errors on failure.""" | ||
| file_path = _validate_path(file_path, allowed_root) | ||
|
|
||
| if not file_path.exists(): | ||
| raise FileNotFoundError(f"File not found: {file_path}") | ||
|
|
||
|
|
@@ -45,8 +66,10 @@ def read_file_content( | |
| return "\n".join(result_lines) | ||
|
|
||
|
|
||
| def write_file_content(file_path: Path, content: str) -> str: | ||
| def write_file_content(file_path: Path, content: str, allowed_root: Path | None = None) -> str: | ||
| """Writes content to a file, creating parent directories if needed.""" | ||
| file_path = _validate_path(file_path, allowed_root) | ||
|
|
||
|
Comment on lines
+69
to
+72
|
||
| try: | ||
| file_path.parent.mkdir(parents=True, exist_ok=True) | ||
| file_path.write_text(content, encoding="utf-8") | ||
|
|
@@ -61,11 +84,14 @@ def edit_file_content( | |
| old_string: str, | ||
| new_string: str, | ||
| replace_all: bool = False, | ||
| allowed_root: Path | None = None, | ||
| ) -> str: | ||
| """Performs an exact string replacement in a file.""" | ||
| if old_string == new_string: | ||
| raise ValueError("old_string and new_string must be different") | ||
|
|
||
| file_path = _validate_path(file_path, allowed_root) | ||
|
|
||
|
Comment on lines
82
to
+94
|
||
| if not file_path.exists(): | ||
| raise FileNotFoundError(f"File not found: {file_path}") | ||
|
|
||
|
|
@@ -110,16 +136,13 @@ def _get_command_timeout_seconds(command: str) -> float: | |
| return 30.0 # 30 seconds for other commands | ||
|
|
||
|
|
||
| async def execute_command( | ||
| command: str, | ||
| working_dir: Path, | ||
| ) -> str: | ||
| async def execute_command(command: str, working_dir: Path, skills_dir: Path) -> str: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you also update the usage of
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. just pushed changed for this! lmk if there is anything else. I made the openai have a default of /skills and try to pull in that env |
||
| """Executes a shell command in a sandboxed environment.""" | ||
| timeout = _get_command_timeout_seconds(command) | ||
|
|
||
| env = os.environ.copy() | ||
| # Add skills directory and working directory to PYTHONPATH | ||
| pythonpath_additions = [str(working_dir), "/skills"] | ||
| pythonpath_additions = [str(working_dir), str(skills_dir)] | ||
| if "PYTHONPATH" in env: | ||
| pythonpath_additions.append(env["PYTHONPATH"]) | ||
| env["PYTHONPATH"] = ":".join(pythonpath_additions) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,11 @@ | |
|
|
||
| from kagent.skills import ( | ||
| discover_skills, | ||
| edit_file_content, | ||
| execute_command, | ||
| load_skill_content, | ||
| read_file_content, | ||
| write_file_content, | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -159,6 +161,88 @@ async def mock_exec(*args, **kwargs): | |
| assert list(args).count(injection_payload) == 1 | ||
|
|
||
|
|
||
| # --- Path traversal tests --- | ||
|
|
||
|
|
||
| def test_read_file_blocks_path_traversal(tmp_path): | ||
| """Reading a file outside the allowed root must raise PermissionError.""" | ||
| outside_file = tmp_path.parent / "outside.txt" | ||
| outside_file.write_text("secret") | ||
|
|
||
| try: | ||
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| read_file_content(outside_file, allowed_root=tmp_path) | ||
|
Comment on lines
+173
to
+174
|
||
| finally: | ||
| outside_file.unlink(missing_ok=True) | ||
|
|
||
|
|
||
| def test_read_file_blocks_relative_traversal(tmp_path): | ||
| """Relative paths like ../foo that escape the root must be blocked.""" | ||
| (tmp_path / "subdir").mkdir() | ||
| outside = tmp_path.parent / "secret.txt" | ||
| outside.write_text("secret") | ||
|
|
||
| try: | ||
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| read_file_content( | ||
| tmp_path / "subdir" / "../../secret.txt", | ||
| allowed_root=tmp_path, | ||
| ) | ||
|
Comment on lines
+186
to
+190
|
||
| finally: | ||
| outside.unlink(missing_ok=True) | ||
|
|
||
|
|
||
| def test_read_file_allows_path_inside_root(tmp_path): | ||
| """Files inside the allowed root should work normally.""" | ||
| f = tmp_path / "hello.txt" | ||
| f.write_text("hello world") | ||
| result = read_file_content(f, allowed_root=tmp_path) | ||
| assert "hello world" in result | ||
|
|
||
|
|
||
| def test_read_file_allows_multiple_roots(tmp_path): | ||
| """Read should succeed when the file is inside any of the allowed roots.""" | ||
| skills_dir = tmp_path / "skills" | ||
| skills_dir.mkdir() | ||
| skill_file = skills_dir / "script.py" | ||
| skill_file.write_text("print('hello')") | ||
|
|
||
| session_dir = tmp_path / "session" | ||
| session_dir.mkdir() | ||
|
|
||
| # File is in skills_dir, not session_dir — should still be allowed | ||
| result = read_file_content(skill_file, allowed_root=[session_dir, skills_dir]) | ||
| assert "print('hello')" in result | ||
|
|
||
| # File outside both roots should be blocked | ||
| outside = tmp_path / "outside.txt" | ||
| outside.write_text("secret") | ||
| with pytest.raises(PermissionError, match="outside the allowed directories"): | ||
| read_file_content(outside, allowed_root=[session_dir, skills_dir]) | ||
|
|
||
|
|
||
| def test_write_file_blocks_path_traversal(tmp_path): | ||
| """Writing a file outside the allowed root must raise PermissionError.""" | ||
| outside_path = tmp_path.parent / "evil.txt" | ||
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| write_file_content(outside_path, "malicious", allowed_root=tmp_path) | ||
| assert not outside_path.exists() | ||
|
Comment on lines
+227
to
+229
|
||
|
|
||
|
|
||
| def test_edit_file_blocks_path_traversal(tmp_path): | ||
| """Editing a file outside the allowed root must raise PermissionError.""" | ||
| outside_file = tmp_path.parent / "target.txt" | ||
| outside_file.write_text("original") | ||
|
|
||
| try: | ||
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| edit_file_content(outside_file, "original", "hacked", allowed_root=tmp_path) | ||
| # File must not have been modified | ||
| assert outside_file.read_text() == "original" | ||
|
Comment on lines
+238
to
+241
|
||
| finally: | ||
| outside_file.unlink(missing_ok=True) | ||
|
|
||
|
|
||
| def test_skill_discovery_and_loading(skill_test_env: Path): | ||
| """ | ||
| Tests the core logic of discovering a skill and loading its instructions. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ReadFileToolhardcodesPath("/skills")as an allowed root. However, ADK supports configuringKAGENT_SKILLS_FOLDER(custom skills directory), and session initialization explicitly notes a fallback where skills may need to be accessed via their absolute path if the symlink cannot be created. Consider using the configured skills directory (or capturing it via plugin/tool initialization) instead of always assuming/skills.