From bd31e84bc6f63c5adba400ca03b46ef8fa054369 Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:42:00 -0500 Subject: [PATCH 1/8] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c9e3acd..8455ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ source-agent = "source_agent.entrypoint:main" [project] requires-python = ">=3.10" -version = "0.0.14" +version = "0.0.15" name = "source-agent" description = "Simple coding agent." readme = ".github/README.md" From 4ce88bfdec550209a66a33229a039289e71b271d Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:42:02 -0500 Subject: [PATCH 2/8] Update entrypoint.py --- src/source_agent/entrypoint.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/source_agent/entrypoint.py b/src/source_agent/entrypoint.py index d6982a9..0d3efad 100644 --- a/src/source_agent/entrypoint.py +++ b/src/source_agent/entrypoint.py @@ -205,6 +205,13 @@ def main() -> int: default=False, help="Run in interactive step-through mode", ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + default=False, + help="Enable verbose output for agent events and tool calls", + ) args = parser.parse_args() From c028cd4b9bf5593e4e70d8e7503eb766db70df36 Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:42:04 -0500 Subject: [PATCH 3/8] Create execute_shell_command_tool.py --- .../tools/execute_shell_command_tool.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/source_agent/tools/execute_shell_command_tool.py diff --git a/src/source_agent/tools/execute_shell_command_tool.py b/src/source_agent/tools/execute_shell_command_tool.py new file mode 100644 index 0000000..1df9066 --- /dev/null +++ b/src/source_agent/tools/execute_shell_command_tool.py @@ -0,0 +1,65 @@ +import subprocess +import pathlib +from .tool_registry import registry + + +@registry.register( + name="execute_shell_command", + description=( + "Executes a shell command. Use this tool with extreme caution. " + "It can run any command on the system where the agent is operating. " + "Outputs stdout, stderr, and exit code." + ), + parameters={ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell command to execute.", + } + }, + "required": ["command"], + }, +) +def execute_shell_command(command: str) -> dict: + """ + Executes a shell command and returns its stdout, stderr, and exit code. + + WARNING: This tool allows arbitrary code execution. Ensure the environment + where this agent runs is properly sandboxed to prevent security risks. + + Args: + command (str): The shell command to execute. + + Returns: + dict: A dictionary containing stdout, stderr, exit_code, success status, and any error message. + """ + try: + # Using shell=True for simplicity, allowing the agent to use pipes, redirects, etc. + # This is where the security risk lies. + process = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + check=False, + cwd=pathlib.Path.cwd(), + ) + + return { + "success": True, + "command": command, + "stdout": process.stdout.strip(), + "stderr": process.stderr.strip(), + "exit_code": process.returncode, + "message": f"Command executed with exit code {process.returncode}", + } + except Exception as e: + return { + "success": False, + "command": command, + "stdout": "", + "stderr": str(e), + "exit_code": 1, # Indicate failure + "error": f"Failed to execute command: {str(e)}", + } \ No newline at end of file From ce871b6fd3f9cc32e02e2c3fb5b8e267b238c575 Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:42:07 -0500 Subject: [PATCH 4/8] Create run_pytest_tests_tool.py --- .../tools/run_pytest_tests_tool.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/source_agent/tools/run_pytest_tests_tool.py diff --git a/src/source_agent/tools/run_pytest_tests_tool.py b/src/source_agent/tools/run_pytest_tests_tool.py new file mode 100644 index 0000000..625f520 --- /dev/null +++ b/src/source_agent/tools/run_pytest_tests_tool.py @@ -0,0 +1,91 @@ +import sys +import subprocess +import pathlib +from typing import List, Optional +from .tool_registry import registry + + +@registry.register( + name="run_pytest_tests", + description="Runs pytest tests in a specified directory or for specific files.", + parameters={ + "type": "object", + "properties": { + "target_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "A list of paths (files or directories) to run pytest on. Defaults to current directory if empty.", + "default": [], + }, + "pytest_args": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional arguments to pass directly to pytest (e.g., ['-k', 'test_my_feature']).", + "default": [], + }, + }, + }, +) +def run_pytest_tests(target_paths: Optional[List[str]] = None, pytest_args: Optional[List[str]] = None) -> dict: + """ + Runs pytest tests in a specified directory or for specific files. + + Args: + target_paths (List[str], optional): Paths to run pytest on. + pytest_args (List[str], optional): Additional arguments for pytest. + + Returns: + dict: A dictionary containing success status, output, and any error message. + """ + cmd = [sys.executable, "-m", "pytest"] + + if target_paths: + for path_str in target_paths: + path = pathlib.Path(path_str).resolve() + cwd = pathlib.Path.cwd().resolve() + if not path.is_relative_to(cwd): + return { + "success": False, + "error": f"Path traversal detected for target_path - {path_str}", + "stdout": "", + "stderr": "", + "exit_code": 1, + } + cmd.append(str(path)) + else: + # If no target_paths, pytest runs in the current directory by default + pass + + if pytest_args: + cmd.extend(pytest_args) + + try: + process = subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, # Pytest returns non-zero on test failures, which is not an execution error + cwd=pathlib.Path.cwd(), + ) + + # Pytest exit codes: 0 (all passed), 1 (tests failed), 2 (internal error), 3 (usage error), 4 (no tests collected), 5 (no tests ran) + # We consider 0 as success, others as failure in the context of the tool's success status + is_success = process.returncode == 0 + + return { + "success": is_success, + "command": " ".join(cmd), + "stdout": process.stdout.strip(), + "stderr": process.stderr.strip(), + "exit_code": process.returncode, + "message": "Pytest execution completed." if is_success else "Pytest tests failed or encountered issues.", + } + except Exception as e: + return { + "success": False, + "command": " ".join(cmd), + "error": f"An unexpected error occurred during pytest execution: {str(e)}", + "stdout": "", + "stderr": "", + "exit_code": 1, + } \ No newline at end of file From bb4b75e3cbb55a5968a1ce6320bd255b80f73daf Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:53:29 -0500 Subject: [PATCH 5/8] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a36794b..12a2c2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Project Specific Ignore List -*.local.* +.git/ .vscode/ +*.local.* # Byte-compiled / optimized / DLL files From 038ffc9f0f3666da900d1168f57cc78e4b219408 Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:53:41 -0500 Subject: [PATCH 6/8] Update execute_shell_command_tool.py --- src/source_agent/tools/execute_shell_command_tool.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/source_agent/tools/execute_shell_command_tool.py b/src/source_agent/tools/execute_shell_command_tool.py index 1df9066..b76667d 100644 --- a/src/source_agent/tools/execute_shell_command_tool.py +++ b/src/source_agent/tools/execute_shell_command_tool.py @@ -1,5 +1,5 @@ -import subprocess import pathlib +import subprocess from .tool_registry import registry @@ -60,6 +60,6 @@ def execute_shell_command(command: str) -> dict: "command": command, "stdout": "", "stderr": str(e), - "exit_code": 1, # Indicate failure + "exit_code": 1, # Indicate failure "error": f"Failed to execute command: {str(e)}", - } \ No newline at end of file + } From 673ae269f84781f4ce0a5e3a07247dc00fd04fcd Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:53:42 -0500 Subject: [PATCH 7/8] Update file_list_tool.py --- src/source_agent/tools/file_list_tool.py | 122 ++++------------------- 1 file changed, 20 insertions(+), 102 deletions(-) diff --git a/src/source_agent/tools/file_list_tool.py b/src/source_agent/tools/file_list_tool.py index e485ef0..ef757f2 100644 --- a/src/source_agent/tools/file_list_tool.py +++ b/src/source_agent/tools/file_list_tool.py @@ -1,66 +1,16 @@ import pathlib import pathspec -from typing import Optional from .tool_registry import registry -def load_gitignore_spec(root: pathlib.Path) -> Optional[pathspec.PathSpec]: - """ - Load a PathSpec object from a .gitignore file in the given root directory. - - Args: - root (pathlib.Path): The root directory to look for .gitignore. - - Returns: - PathSpec or None: A compiled PathSpec or None if .gitignore doesn't exist. - """ +def load_gitignore(root: pathlib.Path): gitignore = root / ".gitignore" if gitignore.exists(): lines = gitignore.read_text(encoding="utf-8").splitlines() - # Compile patterns using gitwildmatch (same as .gitignore) return pathspec.PathSpec.from_lines("gitwildmatch", lines) return None -def is_ignored( - path: pathlib.Path, spec: Optional[pathspec.PathSpec], root: pathlib.Path -) -> bool: - """ - Check if the given path matches the PathSpec patterns. - - Args: - path (pathlib.Path): The path to check. - spec (PathSpec or None): A PathSpec object or None. - root (pathlib.Path): The root directory for relative matching. - - Returns: - bool: True if the path is ignored, False otherwise. - """ - if not spec: - return False - # Convert to POSIX-style relative path for matching - rel = path.relative_to(root).as_posix() - return spec.match_file(rel) - - -def is_subpath(path: pathlib.Path, base: pathlib.Path) -> bool: - """ - Check whether 'path' is a subpath of 'base'. - - Args: - path (pathlib.Path): The path to check. - base (pathlib.Path): The base directory. - - Returns: - bool: True if path is within base, False otherwise. - """ - try: - path.relative_to(base) - return True - except ValueError: - return False - - @registry.register( name="file_list_tool", description="List files and directories in a given path, respecting .gitignore if present.", @@ -69,74 +19,42 @@ def is_subpath(path: pathlib.Path, base: pathlib.Path) -> bool: "properties": { "path": { "type": "string", - "description": "The directory path to list.", "default": ".", + "description": "Directory to list.", }, "recursive": { "type": "boolean", - "description": "Whether to list recursively.", "default": False, + "description": "List recursively.", }, }, "required": [], }, ) -def file_list_tool(path: str = ".", recursive: bool = False) -> dict: - """ - List files and directories, filtered by .gitignore if available. - - Args: - path (str): The directory path to list. - recursive (bool): Whether to list recursively. - - Returns: - dict: A list of files and directories, or an error message if the path is invalid. - """ +def file_list_tool(path=".", recursive=False): cwd = pathlib.Path.cwd().resolve() - try: - # Resolve the user-provided path securely - dir_path = pathlib.Path(path).resolve(strict=True) + target = (cwd / path).resolve(strict=True) except FileNotFoundError: - return { - "error": f"Directory not found - {path}", - "success": False, - } - - if not dir_path.is_dir(): - return { - "error": f"Path is not a directory - {path}", - "success": False, - } + return {"success": False, "error": f"Path not found: {path}"} - # Prevent access to paths outside the working directory - if not is_subpath(dir_path, cwd): - return { - "error": f"Path traversal detected - {path}", - "success": False, - } + if not target.is_dir(): + return {"success": False, "error": f"Not a directory: {path}"} + if not str(target).startswith(str(cwd)): + return {"success": False, "error": "Path traversal not allowed."} - # Load .gitignore patterns (if any) into a PathSpec - spec = load_gitignore_spec(dir_path) + spec = load_gitignore(cwd) + rel = lambda p: p.relative_to(cwd).as_posix() # noqa: E731 items = [] - # Choose recursive or shallow listing - iterator = dir_path.rglob("*") if recursive else dir_path.iterdir() + paths = target.rglob("*") if recursive else target.iterdir() - for item in iterator: - # Skip ignored files/directories - if is_ignored(item, spec, dir_path): + for p in paths: + if spec and spec.match_file(rel(p)): continue + name = rel(p) + if p.is_dir() and not recursive: + name += "/" + items.append(name) - # Format the output relative to the current working directory - formatted = str(item.relative_to(cwd)) - # Indicate non-recursive subdirectories with trailing slash - if item.is_dir() and not recursive: - formatted += "/" - - items.append(formatted) - - return { - "files": sorted(items), - "success": True, - } + return {"success": True, "files": sorted(items)} From d08115ae238c6862702806af43e7db753ef8a6c0 Mon Sep 17 00:00:00 2001 From: Chris <363708+christopherwoodall@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:53:44 -0500 Subject: [PATCH 8/8] Update run_pytest_tests_tool.py --- src/source_agent/tools/run_pytest_tests_tool.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/source_agent/tools/run_pytest_tests_tool.py b/src/source_agent/tools/run_pytest_tests_tool.py index 625f520..3ef6b46 100644 --- a/src/source_agent/tools/run_pytest_tests_tool.py +++ b/src/source_agent/tools/run_pytest_tests_tool.py @@ -1,6 +1,7 @@ +# ruff: noqa: E501 import sys -import subprocess import pathlib +import subprocess from typing import List, Optional from .tool_registry import registry @@ -26,7 +27,9 @@ }, }, ) -def run_pytest_tests(target_paths: Optional[List[str]] = None, pytest_args: Optional[List[str]] = None) -> dict: +def run_pytest_tests( + target_paths: Optional[List[str]] = None, pytest_args: Optional[List[str]] = None +) -> dict: """ Runs pytest tests in a specified directory or for specific files. @@ -78,7 +81,11 @@ def run_pytest_tests(target_paths: Optional[List[str]] = None, pytest_args: Opti "stdout": process.stdout.strip(), "stderr": process.stderr.strip(), "exit_code": process.returncode, - "message": "Pytest execution completed." if is_success else "Pytest tests failed or encountered issues.", + "message": ( + "Pytest execution completed." + if is_success + else "Pytest tests failed or encountered issues." + ), } except Exception as e: return { @@ -88,4 +95,4 @@ def run_pytest_tests(target_paths: Optional[List[str]] = None, pytest_args: Opti "stdout": "", "stderr": "", "exit_code": 1, - } \ No newline at end of file + }