## 🚀 Major Improvements ### Docker Environment Simplification - **BREAKING**: Simplified Docker configuration by auto-detecting sandbox from WORKSPACE_ROOT - Removed redundant MCP_PROJECT_ROOT requirement for Docker setups - Updated all Docker config examples and setup scripts - Added security validation for dangerous WORKSPACE_ROOT paths ### Security Enhancements - **CRITICAL**: Fixed insecure PROJECT_ROOT fallback to use current directory instead of home - Enhanced path validation with proper Docker environment detection - Removed information disclosure in error messages - Strengthened symlink and path traversal protection ### File Handling Optimization - **PERFORMANCE**: Optimized read_files() to return content only (removed summary) - Unified file reading across all tools using standardized file_utils routines - Fixed review_changes tool to use consistent file loading patterns - Improved token management and reduced unnecessary processing ### Tool Improvements - **UX**: Enhanced ReviewCodeTool to require user context for targeted reviews - Removed deprecated _get_secure_container_path function and _sanitize_filename - Standardized file access patterns across analyze, review_changes, and other tools - Added contextual prompting to align reviews with user expectations ### Code Quality & Testing - Updated all tests for new function signatures and requirements - Added comprehensive Docker path integration tests - Achieved 100% test coverage (95 tests passing) - Full compliance with ruff, black, and isort linting standards ### Configuration & Deployment - Added pyproject.toml for modern Python packaging - Streamlined Docker setup removing redundant environment variables - Updated setup scripts across all platforms (Windows, macOS, Linux) - Improved error handling and validation throughout ## 🔧 Technical Changes - **Removed**: `_get_secure_container_path()`, `_sanitize_filename()`, unused SANDBOX_MODE - **Enhanced**: Path translation, security validation, token management - **Standardized**: File reading patterns, error handling, Docker detection - **Updated**: All tool prompts for better context alignment ## 🛡️ Security Notes This release significantly improves the security posture by: - Eliminating broad filesystem access defaults - Adding validation for Docker environment variables - Removing information disclosure in error paths - Strengthening path traversal and symlink protections 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""
|
|
Tests for the review_changes tool
|
|
"""
|
|
|
|
import json
|
|
from unittest.mock import Mock, patch
|
|
|
|
import pytest
|
|
|
|
from tools.review_changes import ReviewChanges, ReviewChangesRequest
|
|
|
|
|
|
class TestReviewChangesTool:
|
|
"""Test the review_changes tool"""
|
|
|
|
@pytest.fixture
|
|
def tool(self):
|
|
"""Create tool instance"""
|
|
return ReviewChanges()
|
|
|
|
def test_tool_metadata(self, tool):
|
|
"""Test tool metadata"""
|
|
assert tool.get_name() == "review_changes"
|
|
assert "REVIEW PENDING GIT CHANGES" in tool.get_description()
|
|
assert "pre-commit review" in tool.get_description()
|
|
|
|
# Check schema
|
|
schema = tool.get_input_schema()
|
|
assert schema["type"] == "object"
|
|
assert "path" in schema["properties"]
|
|
assert "original_request" in schema["properties"]
|
|
assert "compare_to" in schema["properties"]
|
|
assert "review_type" in schema["properties"]
|
|
|
|
def test_request_model_defaults(self):
|
|
"""Test request model default values"""
|
|
request = ReviewChangesRequest(path="/some/absolute/path")
|
|
assert request.path == "/some/absolute/path"
|
|
assert request.original_request is None
|
|
assert request.compare_to is None
|
|
assert request.include_staged is True
|
|
assert request.include_unstaged is True
|
|
assert request.review_type == "full"
|
|
assert request.severity_filter == "all"
|
|
assert request.max_depth == 5
|
|
assert request.files is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_relative_path_rejected(self, tool):
|
|
"""Test that relative paths are rejected"""
|
|
result = await tool.execute({"path": "./relative/path", "original_request": "Test"})
|
|
assert len(result) == 1
|
|
response = json.loads(result[0].text)
|
|
assert response["status"] == "error"
|
|
assert "must be absolute" in response["content"]
|
|
assert "./relative/path" in response["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
async def test_no_repositories_found(self, mock_find_repos, tool):
|
|
"""Test when no git repositories are found"""
|
|
mock_find_repos.return_value = []
|
|
|
|
request = ReviewChangesRequest(path="/absolute/path/no-git")
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
assert result == "No git repositories found in the specified path."
|
|
mock_find_repos.assert_called_once_with("/absolute/path/no-git", 5)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
async def test_no_changes_found(self, mock_run_git, mock_status, mock_find_repos, tool):
|
|
"""Test when repositories have no changes"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {
|
|
"branch": "main",
|
|
"ahead": 0,
|
|
"behind": 0,
|
|
"staged_files": [],
|
|
"unstaged_files": [],
|
|
"untracked_files": [],
|
|
}
|
|
|
|
# No staged or unstaged files
|
|
mock_run_git.side_effect = [
|
|
(True, ""), # staged files (empty)
|
|
(True, ""), # unstaged files (empty)
|
|
]
|
|
|
|
request = ReviewChangesRequest(path="/absolute/repo/path")
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
assert result == "No pending changes found in any of the git repositories."
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
async def test_staged_changes_review(
|
|
self,
|
|
mock_run_git,
|
|
mock_status,
|
|
mock_find_repos,
|
|
tool,
|
|
):
|
|
"""Test reviewing staged changes"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {
|
|
"branch": "feature",
|
|
"ahead": 1,
|
|
"behind": 0,
|
|
"staged_files": ["main.py"],
|
|
"unstaged_files": [],
|
|
"untracked_files": [],
|
|
}
|
|
|
|
# Mock git commands
|
|
mock_run_git.side_effect = [
|
|
(True, "main.py\n"), # staged files
|
|
(
|
|
True,
|
|
"diff --git a/main.py b/main.py\n+print('hello')",
|
|
), # diff for main.py
|
|
(True, ""), # unstaged files (empty)
|
|
]
|
|
|
|
request = ReviewChangesRequest(
|
|
path="/absolute/repo/path",
|
|
original_request="Add hello message",
|
|
review_type="security",
|
|
)
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
# Verify result structure
|
|
assert "## Original Request/Ticket" in result
|
|
assert "Add hello message" in result
|
|
assert "## Review Parameters" in result
|
|
assert "Review Type: security" in result
|
|
assert "## Repository Changes Summary" in result
|
|
assert "Branch: feature" in result
|
|
assert "## Git Diffs" in result
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
async def test_compare_to_invalid_ref(self, mock_run_git, mock_status, mock_find_repos, tool):
|
|
"""Test comparing to an invalid git ref"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {"branch": "main"}
|
|
|
|
# Mock git commands - ref validation fails
|
|
mock_run_git.side_effect = [
|
|
(False, "fatal: not a valid ref"), # rev-parse fails
|
|
]
|
|
|
|
request = ReviewChangesRequest(path="/absolute/repo/path", compare_to="invalid-branch")
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
# When all repos have errors and no changes, we get this message
|
|
assert "No pending changes found in any of the git repositories." in result
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.ReviewChanges.execute")
|
|
async def test_execute_integration(self, mock_execute, tool):
|
|
"""Test execute method integration"""
|
|
# Mock the execute to return a standardized response
|
|
mock_execute.return_value = [
|
|
Mock(text='{"status": "success", "content": "Review complete", "content_type": "text"}')
|
|
]
|
|
|
|
result = await tool.execute({"path": ".", "review_type": "full"})
|
|
|
|
assert len(result) == 1
|
|
mock_execute.assert_called_once()
|
|
|
|
def test_default_temperature(self, tool):
|
|
"""Test default temperature setting"""
|
|
from config import TEMPERATURE_ANALYTICAL
|
|
|
|
assert tool.get_default_temperature() == TEMPERATURE_ANALYTICAL
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
async def test_mixed_staged_unstaged_changes(
|
|
self,
|
|
mock_run_git,
|
|
mock_status,
|
|
mock_find_repos,
|
|
tool,
|
|
):
|
|
"""Test reviewing both staged and unstaged changes"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {
|
|
"branch": "develop",
|
|
"ahead": 2,
|
|
"behind": 1,
|
|
"staged_files": ["file1.py"],
|
|
"unstaged_files": ["file2.py"],
|
|
}
|
|
|
|
# Mock git commands
|
|
mock_run_git.side_effect = [
|
|
(True, "file1.py\n"), # staged files
|
|
(True, "diff --git a/file1.py..."), # diff for file1.py
|
|
(True, "file2.py\n"), # unstaged files
|
|
(True, "diff --git a/file2.py..."), # diff for file2.py
|
|
]
|
|
|
|
request = ReviewChangesRequest(
|
|
path="/absolute/repo/path",
|
|
focus_on="error handling",
|
|
severity_filter="high",
|
|
)
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
# Verify all sections are present
|
|
assert "Review Type: full" in result
|
|
assert "Severity Filter: high" in result
|
|
assert "Focus Areas: error handling" in result
|
|
assert "Reviewing: staged and unstaged changes" in result
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
@patch("tools.review_changes.read_files")
|
|
async def test_files_parameter_with_context(
|
|
self,
|
|
mock_read_files,
|
|
mock_run_git,
|
|
mock_status,
|
|
mock_find_repos,
|
|
tool,
|
|
):
|
|
"""Test review with additional context files"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {
|
|
"branch": "main",
|
|
"ahead": 0,
|
|
"behind": 0,
|
|
"staged_files": ["file1.py"],
|
|
"unstaged_files": [],
|
|
}
|
|
|
|
# Mock git commands - need to match all calls in prepare_prompt
|
|
mock_run_git.side_effect = [
|
|
(True, "file1.py\n"), # staged files list
|
|
(True, "diff --git a/file1.py..."), # diff for file1.py
|
|
(True, ""), # unstaged files list (empty)
|
|
]
|
|
|
|
# Mock read_files
|
|
mock_read_files.return_value = "=== FILE: config.py ===\nCONFIG_VALUE = 42\n=== END FILE ==="
|
|
|
|
request = ReviewChangesRequest(
|
|
path="/absolute/repo/path",
|
|
files=["/absolute/repo/path/config.py"],
|
|
)
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
# Verify context files are included
|
|
assert "## Context Files Summary" in result
|
|
assert "✅ Included: 1 context files" in result
|
|
assert "## Additional Context Files" in result
|
|
assert "=== FILE: config.py ===" in result
|
|
assert "CONFIG_VALUE = 42" in result
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("tools.review_changes.find_git_repositories")
|
|
@patch("tools.review_changes.get_git_status")
|
|
@patch("tools.review_changes.run_git_command")
|
|
async def test_files_request_instruction(
|
|
self,
|
|
mock_run_git,
|
|
mock_status,
|
|
mock_find_repos,
|
|
tool,
|
|
):
|
|
"""Test that file request instruction is added when no files provided"""
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_status.return_value = {
|
|
"branch": "main",
|
|
"ahead": 0,
|
|
"behind": 0,
|
|
"staged_files": ["file1.py"],
|
|
"unstaged_files": [],
|
|
}
|
|
|
|
mock_run_git.side_effect = [
|
|
(True, "file1.py\n"), # staged files
|
|
(True, "diff --git a/file1.py..."), # diff for file1.py
|
|
(True, ""), # unstaged files (empty)
|
|
]
|
|
|
|
# Request without files
|
|
request = ReviewChangesRequest(path="/absolute/repo/path")
|
|
result = await tool.prepare_prompt(request)
|
|
|
|
# Should include instruction for requesting files
|
|
assert "If you need additional context files" in result
|
|
assert "standardized JSON response format" in result
|
|
|
|
# Request with files - should not include instruction
|
|
request_with_files = ReviewChangesRequest(path="/absolute/repo/path", files=["/some/file.py"])
|
|
|
|
# Need to reset mocks for second call
|
|
mock_find_repos.return_value = ["/test/repo"]
|
|
mock_run_git.side_effect = [
|
|
(True, "file1.py\n"), # staged files
|
|
(True, "diff --git a/file1.py..."), # diff for file1.py
|
|
(True, ""), # unstaged files (empty)
|
|
]
|
|
|
|
# Mock read_files to return empty (file not found)
|
|
with patch("tools.review_changes.read_files") as mock_read:
|
|
mock_read.return_value = ""
|
|
result_with_files = await tool.prepare_prompt(request_with_files)
|
|
|
|
assert "If you need additional context files" not in result_with_files
|