fix(file tools) implement directory restrictions to file tools#1496
fix(file tools) implement directory restrictions to file tools#1496EItanya merged 5 commits intokagent-dev:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses #1495 by restricting filesystem access in the file operation tools so they can only operate within the session working directory (and, for reads, the skills directory), reducing the risk of unintended host filesystem access and secret leakage.
Changes:
- Added centralized path validation to block path traversal / access outside configured allowed root(s).
- Updated OpenAI and ADK tool wrappers to pass allowed roots into the underlying file operations.
- Added unit tests covering allowed/blocked read/write/edit behavior, including traversal attempts.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
python/packages/kagent-skills/src/kagent/skills/shell.py |
Adds _validate_path and threads allowed_root through file helpers to enforce directory restrictions. |
python/packages/kagent-openai/src/kagent/openai/tools/_tools.py |
Applies directory restrictions for OpenAI tool wrappers by passing allowed_root to file helpers. |
python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py |
Applies directory restrictions for ADK tool wrappers by passing allowed_root to file helpers. |
python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py |
Adds unit tests to validate traversal is blocked and in-root access works. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| write_file_content(outside_path, "malicious", allowed_root=tmp_path) | ||
| assert not outside_path.exists() |
There was a problem hiding this comment.
The match pattern uses the misspelling "director"; consider using a correctly-spelled pattern that also matches both singular/plural (e.g., directory/directories) to keep the assertion clear and robust.
| 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" |
There was a problem hiding this comment.
The match pattern uses the misspelling "director"; consider using a correctly-spelled pattern that also matches both singular/plural (e.g., directory/directories) to keep the assertion clear and robust.
|
|
||
| return read_file_content(path, offset, limit) | ||
| except (FileNotFoundError, IsADirectoryError, OSError) as e: | ||
| return read_file_content(path, offset, limit, allowed_root=[working_dir, Path("/skills")]) |
There was a problem hiding this comment.
read_file hardcodes Path("/skills") as an allowed root. Since this module supports custom skills_directory via get_skill_tool(s), this can unintentionally block reading skill files by absolute path when skills are mounted elsewhere (and the session symlink creation fallback in initialize_session_path mentions accessing skills via absolute path). Consider deriving the allowed skills root from the configured skills_directory (e.g., by closing over it in a factory) rather than assuming /skills.
| return read_file_content(path, offset, limit, allowed_root=[working_dir, Path("/skills")]) | |
| allowed_roots = [working_dir] | |
| skills_directory = getattr(wrapper.context, "skills_directory", None) | |
| if skills_directory is not None: | |
| allowed_roots.append(Path(skills_directory)) | |
| else: | |
| # Fallback to the default skills mount point for backwards compatibility. | |
| allowed_roots.append(Path("/skills")) | |
| return read_file_content(path, offset, limit, allowed_root=allowed_roots) |
| working_dir = get_session_path(session_id=tool_context.session.id) | ||
| path = Path(file_path_str) | ||
| if not path.is_absolute(): | ||
| path = working_dir / path | ||
| path = path.resolve() | ||
|
|
||
| return read_file_content(path, offset, limit) | ||
| except (FileNotFoundError, IsADirectoryError, IOError) as e: | ||
| return read_file_content(path, offset, limit, allowed_root=[working_dir, Path("/skills")]) | ||
| except (FileNotFoundError, IsADirectoryError, PermissionError, IOError) as e: | ||
| return f"Error reading file {file_path_str}: {e}" |
There was a problem hiding this comment.
ReadFileTool hardcodes Path("/skills") as an allowed root. However, ADK supports configuring KAGENT_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.
| 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) | ||
|
|
There was a problem hiding this comment.
write_file_content only type-hints allowed_root as Path | None, but it is passed directly to _validate_path which supports Path | list[Path] | None and read_file_content already exposes list support. Align the signature/type hints (and naming, e.g. allowed_roots) to avoid an inconsistent public API and enable reuse when multiple allowed roots are needed.
| def edit_file_content( | ||
| file_path: Path, | ||
| 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) | ||
|
|
There was a problem hiding this comment.
edit_file_content only type-hints allowed_root as Path | None, but _validate_path and read_file_content support multiple allowed roots. Consider aligning the signature/type hints (and parameter naming) across the file tools for consistency and to avoid callers working around the mismatch.
python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py
Show resolved
Hide resolved
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| read_file_content(outside_file, allowed_root=tmp_path) |
There was a problem hiding this comment.
The match pattern uses the misspelling "director"; consider using a correctly-spelled pattern that also matches both singular/plural (e.g., directory/directories) to keep the assertion clear and robust.
| with pytest.raises(PermissionError, match="outside the allowed director"): | ||
| read_file_content( | ||
| tmp_path / "subdir" / "../../secret.txt", | ||
| allowed_root=tmp_path, | ||
| ) |
There was a problem hiding this comment.
The match pattern uses the misspelling "director"; consider using a correctly-spelled pattern that also matches both singular/plural (e.g., directory/directories) to keep the assertion clear and robust.
This limits the file tools to either the current working directory or the skills repo write_file & edit_file - restricted to just working directory read_file - restricted to working directory & skills directory Signed-off-by: Josh Holt <jholt96@live.com>
use skills dir instead of hardcoded path Signed-off-by: Josh Holt <jholt96@live.com>
5ebab86 to
773a54d
Compare
Signed-off-by: Josh Holt <jholt96@live.com>
Signed-off-by: Josh Holt <jholt96@live.com>
supreme-gg-gg
left a comment
There was a problem hiding this comment.
Agreed we should restrict directory, one nit
| command: str, | ||
| working_dir: Path, | ||
| ) -> str: | ||
| async def execute_command(command: str, working_dir: Path, skills_dir: Path) -> str: |
There was a problem hiding this comment.
Can you also update the usage of execute_command in test_skill_execution.py and kagent/openai/tools/_tools.py to pass in the skills_dir argument or make the skills dir argument optional?
There was a problem hiding this comment.
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
Signed-off-by: Josh Holt <jholt96@live.com>
This limits the file tools to either the current working directory or the skills repo
write_file & edit_file - restricted to just working directory
read_file - restricted to working directory & skills directory
implements: #1495