Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ agent = Agent(
tools=[
SkillsTool(skills_directory="./skills"),
BashTool(skills_directory="./skills"),
ReadFileTool(),
ReadFileTool(skills_directory="./skills"),
WriteFileTool(),
EditFileTool(),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) ->

try:
working_dir = get_session_path(session_id=tool_context.session.id)
result = await execute_command(command, working_dir)
result = await execute_command(command, working_dir, self.skills_directory)
logger.info(f"Executed bash command: {command}, description: {description}")
return result
except Exception as e:
Expand Down
17 changes: 10 additions & 7 deletions python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,14 @@
class ReadFileTool(BaseTool):
"""Read files with line numbers for precise editing."""

def __init__(self):
def __init__(self, skills_directory: str | Path):
super().__init__(
name="read_file",
description=get_read_file_description(),
)
self.skills_directory = Path(skills_directory).resolve()
if not self.skills_directory.exists():
raise ValueError(f"Skills directory does not exist: {self.skills_directory}")

def _get_declaration(self) -> types.FunctionDeclaration:
return types.FunctionDeclaration(
Expand Down Expand Up @@ -75,8 +78,8 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) ->
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(self.skills_directory)])
except (FileNotFoundError, IsADirectoryError, PermissionError, IOError) as e:
return f"Error reading file {file_path_str}: {e}"
Comment on lines -75 to 83

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.


Expand Down Expand Up @@ -124,8 +127,8 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) ->
path = working_dir / path
path = path.resolve()

return write_file_content(path, content)
except IOError as e:
return write_file_content(path, content, allowed_root=working_dir)
except (PermissionError, IOError) as e:
error_msg = f"Error writing file {file_path_str}: {e}"
logger.error(error_msg)
return error_msg
Expand Down Expand Up @@ -185,8 +188,8 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) ->
path = working_dir / path
path = path.resolve()

return edit_file_content(path, old_string, new_string, replace_all)
except (FileNotFoundError, IsADirectoryError, ValueError, IOError) as e:
return edit_file_content(path, old_string, new_string, replace_all, allowed_root=working_dir)
except (FileNotFoundError, IsADirectoryError, ValueError, PermissionError, IOError) as e:
error_msg = f"Error editing file {file_path_str}: {e}"
logger.error(error_msg)
return error_msg
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def add_skills_tool_to_agent(skills_directory: str | Path, agent: BaseAgent) ->
logger.debug(f"Added bash tool to agent: {agent.name}")

if "read_file" not in existing_tool_names:
agent.tools.append(ReadFileTool())
agent.tools.append(ReadFileTool(skills_directory))
logger.debug(f"Added read file tool to agent: {agent.name}")

if "write_file" not in existing_tool_names:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def __init__(self, skills_directory: str | Path):

# Create skills tools
self.skills_tool = SkillsTool(skills_directory)
self.read_file_tool = ReadFileTool()
self.read_file_tool = ReadFileTool(skills_directory)
self.write_file_tool = WriteFileTool()
self.edit_file_tool = EditFileTool()
self.bash_tool = BashTool(skills_directory)
Expand Down
15 changes: 11 additions & 4 deletions python/packages/kagent-openai/src/kagent/openai/tools/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from agents.exceptions import UserError
from agents.run_context import RunContextWrapper
from agents.tool import FunctionTool, function_tool
import os

from kagent.skills import (
discover_skills,
Expand Down Expand Up @@ -55,8 +56,14 @@ def read_file(
if not path.is_absolute():
path = working_dir / path

return read_file_content(path, offset, limit)
except (FileNotFoundError, IsADirectoryError, OSError) as e:
allowed_dirs = [working_dir]

skills_directory = os.getenv("KAGENT_SKILLS_FOLDER", None)
if skills_directory:
allowed_dirs.append(Path(skills_directory))

return read_file_content(path, offset, limit, allowed_root=allowed_dirs)
except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as e:
raise UserError(str(e)) from e


Expand All @@ -73,7 +80,7 @@ def write_file(wrapper: RunContextWrapper[SessionContext], file_path: str, conte
if not path.is_absolute():
path = working_dir / path

return write_file_content(path, content)
return write_file_content(path, content, allowed_root=working_dir)
except OSError as e:
raise UserError(str(e)) from e

Expand All @@ -97,7 +104,7 @@ def edit_file(
if not path.is_absolute():
path = working_dir / path

return edit_file_content(path, old_string, new_string, replace_all)
return edit_file_content(path, old_string, new_string, replace_all, allowed_root=working_dir)
except (FileNotFoundError, IsADirectoryError, ValueError, OSError) as e:
raise UserError(str(e)) from e

Expand Down
35 changes: 29 additions & 6 deletions python/packages/kagent-skills/src/kagent/skills/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand All @@ -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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
Expand All @@ -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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
if not file_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")

Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

from kagent.skills import (
discover_skills,
edit_file_content,
execute_command,
load_skill_content,
read_file_content,
write_file_content,
)


Expand Down Expand Up @@ -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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.


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

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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.
Expand Down
Loading