patchpal 0.4.3__tar.gz → 0.4.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {patchpal-0.4.3/patchpal.egg-info → patchpal-0.4.5}/PKG-INFO +1 -1
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/__init__.py +1 -1
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/agent.py +8 -6
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/permissions.py +41 -3
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/tools.py +181 -6
- {patchpal-0.4.3 → patchpal-0.4.5/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal.egg-info/SOURCES.txt +1 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_agent.py +37 -6
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_guardrails.py +233 -57
- patchpal-0.4.5/tests/test_permissions.py +302 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_tools.py +29 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/LICENSE +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/MANIFEST.in +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/README.md +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/cli.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/context.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/skills.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal/system_prompt.md +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/pyproject.toml +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/setup.cfg +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_cli.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_context.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_operational_safety.py +0 -0
- {patchpal-0.4.3 → patchpal-0.4.5}/tests/test_skills.py +0 -0
|
@@ -714,8 +714,8 @@ def _supports_prompt_caching(model_id: str) -> bool:
|
|
|
714
714
|
# Anthropic models support caching (direct API or via Bedrock)
|
|
715
715
|
if "anthropic" in model_id.lower() or "claude" in model_id.lower():
|
|
716
716
|
return True
|
|
717
|
-
# Bedrock
|
|
718
|
-
if model_id.startswith("bedrock/") and "
|
|
717
|
+
# Bedrock Nova models support caching
|
|
718
|
+
if model_id.startswith("bedrock/") and "amazon.nova" in model_id.lower():
|
|
719
719
|
return True
|
|
720
720
|
return False
|
|
721
721
|
|
|
@@ -738,11 +738,13 @@ def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List
|
|
|
738
738
|
return messages
|
|
739
739
|
|
|
740
740
|
# Determine cache marker format based on provider
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
741
|
+
# Anthropic models (direct or via Bedrock) use cache_control
|
|
742
|
+
# Other Bedrock models (Nova, etc.) use cachePoint
|
|
743
|
+
if model_id.startswith("bedrock/") and "anthropic" not in model_id.lower():
|
|
744
|
+
# Non-Anthropic Bedrock models (Nova, etc.) use cachePoint
|
|
745
|
+
cache_marker = {"cachePoint": {"type": "default"}}
|
|
744
746
|
else:
|
|
745
|
-
#
|
|
747
|
+
# Anthropic models (direct or via Bedrock) use cache_control
|
|
746
748
|
cache_marker = {"cache_control": {"type": "ephemeral"}}
|
|
747
749
|
|
|
748
750
|
# Find system messages (usually at the start)
|
|
@@ -105,14 +105,19 @@ class PermissionManager:
|
|
|
105
105
|
self.session_grants[tool_name] = True
|
|
106
106
|
|
|
107
107
|
def request_permission(
|
|
108
|
-
self,
|
|
108
|
+
self,
|
|
109
|
+
tool_name: str,
|
|
110
|
+
description: str,
|
|
111
|
+
pattern: Optional[str] = None,
|
|
112
|
+
context: Optional[str] = None,
|
|
109
113
|
) -> bool:
|
|
110
114
|
"""Request permission from user to execute a tool.
|
|
111
115
|
|
|
112
116
|
Args:
|
|
113
117
|
tool_name: Name of the tool (e.g., 'run_shell', 'apply_patch')
|
|
114
118
|
description: Human-readable description of what will be executed
|
|
115
|
-
pattern: Optional pattern for matching (e.g., 'pytest' for pytest commands)
|
|
119
|
+
pattern: Optional pattern for matching (e.g., 'pytest' for pytest commands, 'python:/tmp' for python in /tmp)
|
|
120
|
+
context: Optional context string for display (e.g., working directory)
|
|
116
121
|
|
|
117
122
|
Returns:
|
|
118
123
|
True if permission granted, False otherwise
|
|
@@ -135,10 +140,43 @@ class PermissionManager:
|
|
|
135
140
|
sys.stderr.write("-" * 80 + "\n")
|
|
136
141
|
|
|
137
142
|
# Get user input
|
|
143
|
+
# Get the actual repository root for display (match Claude Code's UX)
|
|
144
|
+
from pathlib import Path
|
|
145
|
+
|
|
146
|
+
repo_root = Path(".").resolve()
|
|
147
|
+
|
|
138
148
|
sys.stderr.write("\nDo you want to proceed?\n")
|
|
139
149
|
sys.stderr.write(" 1. Yes\n")
|
|
140
150
|
if pattern:
|
|
141
|
-
|
|
151
|
+
# For file operations, pattern is the directory (e.g., "tmp/")
|
|
152
|
+
# For shell commands, pattern is the command name (e.g., "python")
|
|
153
|
+
if tool_name in ("edit_file", "apply_patch"):
|
|
154
|
+
# File operation - show directory context
|
|
155
|
+
if pattern.endswith("/"):
|
|
156
|
+
# Outside repo - directory pattern like "tmp/"
|
|
157
|
+
sys.stderr.write(
|
|
158
|
+
f" 2. Yes, and don't ask again this session for edits in {pattern}\n"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
# Inside repo - file path pattern
|
|
162
|
+
sys.stderr.write(
|
|
163
|
+
f" 2. Yes, and don't ask again this session for edits to {pattern}\n"
|
|
164
|
+
)
|
|
165
|
+
elif tool_name == "run_shell":
|
|
166
|
+
# Shell command - show working directory context
|
|
167
|
+
# Extract command name from pattern (could be "python" or "python@/tmp")
|
|
168
|
+
# Using @ separator for cross-platform compatibility (: conflicts with Windows paths)
|
|
169
|
+
command_name = pattern.split("@")[0] if "@" in pattern else pattern
|
|
170
|
+
|
|
171
|
+
# Use context (working_dir) if provided, otherwise use repo_root
|
|
172
|
+
display_dir = context if context else str(repo_root)
|
|
173
|
+
|
|
174
|
+
sys.stderr.write(
|
|
175
|
+
f" 2. Yes, and don't ask again this session for '{command_name}' commands in {display_dir}\n"
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
# Other tools
|
|
179
|
+
sys.stderr.write(f" 2. Yes, and don't ask again this session for '{pattern}'\n")
|
|
142
180
|
else:
|
|
143
181
|
sys.stderr.write(f" 2. Yes, and don't ask again this session for {tool_name}\n")
|
|
144
182
|
sys.stderr.write(" 3. No, and tell me what to do differently\n")
|
|
@@ -589,9 +589,27 @@ def _is_binary_file(path: Path) -> bool:
|
|
|
589
589
|
if not path.exists():
|
|
590
590
|
return False
|
|
591
591
|
|
|
592
|
+
# Text-based application MIME types that should be treated as text
|
|
593
|
+
text_application_mimes = {
|
|
594
|
+
"application/json",
|
|
595
|
+
"application/xml",
|
|
596
|
+
"application/javascript",
|
|
597
|
+
"application/x-yaml",
|
|
598
|
+
"application/x-sh",
|
|
599
|
+
"application/x-shellscript",
|
|
600
|
+
"application/x-python",
|
|
601
|
+
"application/x-perl",
|
|
602
|
+
"application/x-ruby",
|
|
603
|
+
"application/x-php",
|
|
604
|
+
}
|
|
605
|
+
|
|
592
606
|
# Check MIME type first
|
|
593
607
|
mime_type, _ = mimetypes.guess_type(str(path))
|
|
594
|
-
if mime_type
|
|
608
|
+
if mime_type:
|
|
609
|
+
# Allow text/* and whitelisted application/* types
|
|
610
|
+
if mime_type.startswith("text/") or mime_type in text_application_mimes:
|
|
611
|
+
return False
|
|
612
|
+
# Everything else is binary
|
|
595
613
|
return True
|
|
596
614
|
|
|
597
615
|
# Fallback: check for null bytes in first 8KB
|
|
@@ -605,7 +623,47 @@ def _is_binary_file(path: Path) -> bool:
|
|
|
605
623
|
|
|
606
624
|
def _is_inside_repo(path: Path) -> bool:
|
|
607
625
|
"""Check if a path is inside the repository."""
|
|
608
|
-
|
|
626
|
+
try:
|
|
627
|
+
# Use is_relative_to() for proper path comparison (available in Python 3.9+)
|
|
628
|
+
# This handles case-insensitivity on Windows and symbolic links properly
|
|
629
|
+
path.relative_to(REPO_ROOT)
|
|
630
|
+
return True
|
|
631
|
+
except ValueError:
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _get_permission_pattern_for_path(path: str, resolved_path: Path) -> str:
|
|
636
|
+
"""Get permission pattern for a file path (matches Claude Code's behavior).
|
|
637
|
+
|
|
638
|
+
For paths outside the repository, uses the directory (like Claude Code shows "tmp/").
|
|
639
|
+
For paths inside the repository, uses the relative path from repo root.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
path: Original path string from user
|
|
643
|
+
resolved_path: Resolved absolute path
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Pattern string for permission matching
|
|
647
|
+
|
|
648
|
+
Example:
|
|
649
|
+
../../../../../tmp/test.py -> "tmp/" (directory for files outside repo)
|
|
650
|
+
src/app.py -> "src/app.py" (relative path for files inside repo)
|
|
651
|
+
"""
|
|
652
|
+
# If inside repository, use relative path from repo root
|
|
653
|
+
if _is_inside_repo(resolved_path):
|
|
654
|
+
try:
|
|
655
|
+
relative = resolved_path.relative_to(REPO_ROOT)
|
|
656
|
+
# Use forward slashes for cross-platform consistency
|
|
657
|
+
return str(relative).replace("\\", "/")
|
|
658
|
+
except ValueError:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
# Outside repository: use directory name (match Claude Code)
|
|
662
|
+
# e.g., /tmp/test.py -> "tmp/"
|
|
663
|
+
# e.g., /home/user/other/file.py -> "other/"
|
|
664
|
+
parent = resolved_path.parent
|
|
665
|
+
dir_name = parent.name if parent.name else str(parent)
|
|
666
|
+
return f"{dir_name}/"
|
|
609
667
|
|
|
610
668
|
|
|
611
669
|
def require_permission_for_read(tool_name: str, get_description, get_pattern=None):
|
|
@@ -1802,6 +1860,9 @@ def apply_patch(path: str, new_content: str) -> str:
|
|
|
1802
1860
|
operation = "Create" if not p.exists() else "Update"
|
|
1803
1861
|
diff_display = _format_colored_diff(old_content, new_content, file_path=path)
|
|
1804
1862
|
|
|
1863
|
+
# Get permission pattern (directory for outside repo, relative path for inside)
|
|
1864
|
+
permission_pattern = _get_permission_pattern_for_path(path, p)
|
|
1865
|
+
|
|
1805
1866
|
# Add warning if writing outside repository
|
|
1806
1867
|
outside_repo_warning = ""
|
|
1807
1868
|
if not _is_inside_repo(p):
|
|
@@ -1809,7 +1870,9 @@ def apply_patch(path: str, new_content: str) -> str:
|
|
|
1809
1870
|
|
|
1810
1871
|
description = f" ● {operation}({path}){outside_repo_warning}\n{diff_display}"
|
|
1811
1872
|
|
|
1812
|
-
if not permission_manager.request_permission(
|
|
1873
|
+
if not permission_manager.request_permission(
|
|
1874
|
+
"apply_patch", description, pattern=permission_pattern
|
|
1875
|
+
):
|
|
1813
1876
|
return "Operation cancelled by user."
|
|
1814
1877
|
|
|
1815
1878
|
# Check git status for uncommitted changes (only for files inside repo)
|
|
@@ -1982,6 +2045,9 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1982
2045
|
# Format colored diff for permission prompt (use adjusted_new_string so user sees what will actually be written)
|
|
1983
2046
|
diff_display = _format_colored_diff(matched_string, adjusted_new_string, file_path=path)
|
|
1984
2047
|
|
|
2048
|
+
# Get permission pattern (directory for outside repo, relative path for inside)
|
|
2049
|
+
permission_pattern = _get_permission_pattern_for_path(path, p)
|
|
2050
|
+
|
|
1985
2051
|
# Add warning if writing outside repository
|
|
1986
2052
|
outside_repo_warning = ""
|
|
1987
2053
|
if not _is_inside_repo(p):
|
|
@@ -1989,7 +2055,9 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1989
2055
|
|
|
1990
2056
|
description = f" ● Update({path}){outside_repo_warning}\n{diff_display}"
|
|
1991
2057
|
|
|
1992
|
-
if not permission_manager.request_permission(
|
|
2058
|
+
if not permission_manager.request_permission(
|
|
2059
|
+
"edit_file", description, pattern=permission_pattern
|
|
2060
|
+
):
|
|
1993
2061
|
return "Operation cancelled by user."
|
|
1994
2062
|
|
|
1995
2063
|
# Backup if enabled
|
|
@@ -2496,6 +2564,101 @@ def web_search(query: str, max_results: int = 5) -> str:
|
|
|
2496
2564
|
raise ValueError(f"Web search failed: {e}")
|
|
2497
2565
|
|
|
2498
2566
|
|
|
2567
|
+
def _extract_shell_command_info(cmd: str) -> tuple[Optional[str], Optional[str]]:
|
|
2568
|
+
"""Extract the meaningful command pattern and working directory from a shell command.
|
|
2569
|
+
|
|
2570
|
+
Handles compound commands (&&, ||, ;, |) by identifying the primary
|
|
2571
|
+
command being executed and any cd commands that change the working directory.
|
|
2572
|
+
|
|
2573
|
+
Args:
|
|
2574
|
+
cmd: The shell command string
|
|
2575
|
+
|
|
2576
|
+
Returns:
|
|
2577
|
+
Tuple of (command_pattern, working_directory)
|
|
2578
|
+
- command_pattern: The primary command name (e.g., 'python')
|
|
2579
|
+
- working_directory: The directory if cd is used, None otherwise
|
|
2580
|
+
|
|
2581
|
+
Examples:
|
|
2582
|
+
>>> _extract_shell_command_info("pytest tests/")
|
|
2583
|
+
('pytest', None)
|
|
2584
|
+
>>> _extract_shell_command_info("cd /tmp && python script.py")
|
|
2585
|
+
('python', '/tmp')
|
|
2586
|
+
>>> _extract_shell_command_info("cd src && ls -la | grep test")
|
|
2587
|
+
('ls', 'src')
|
|
2588
|
+
"""
|
|
2589
|
+
if not cmd or not cmd.strip():
|
|
2590
|
+
return None, None
|
|
2591
|
+
|
|
2592
|
+
# Shell operators that indicate compound commands
|
|
2593
|
+
# Split by && and || first (they group tighter than ;)
|
|
2594
|
+
compound_operators = ["&&", "||", ";"]
|
|
2595
|
+
|
|
2596
|
+
# Split by compound operators to find all sub-commands
|
|
2597
|
+
commands = [cmd]
|
|
2598
|
+
for op in compound_operators:
|
|
2599
|
+
new_commands = []
|
|
2600
|
+
for c in commands:
|
|
2601
|
+
# Split but keep track of which parts are commands
|
|
2602
|
+
parts = c.split(op)
|
|
2603
|
+
new_commands.extend(parts)
|
|
2604
|
+
commands = new_commands
|
|
2605
|
+
|
|
2606
|
+
# Now also handle pipes within each command
|
|
2607
|
+
# Pipes are different - we want the first command in a pipe chain
|
|
2608
|
+
pipe_split_commands = []
|
|
2609
|
+
for c in commands:
|
|
2610
|
+
pipe_parts = c.split("|")
|
|
2611
|
+
# For pipes, we only care about the first command (before the pipe)
|
|
2612
|
+
pipe_split_commands.append(pipe_parts[0])
|
|
2613
|
+
|
|
2614
|
+
commands = pipe_split_commands
|
|
2615
|
+
|
|
2616
|
+
# Commands that change directory or set context (not the actual operation)
|
|
2617
|
+
context_commands = {"cd", "pushd", "popd"}
|
|
2618
|
+
setup_commands = {"export", "set", "unset", "source", "."}
|
|
2619
|
+
|
|
2620
|
+
# Track if we see a cd command and what directory it goes to
|
|
2621
|
+
working_dir = None
|
|
2622
|
+
primary_command = None
|
|
2623
|
+
|
|
2624
|
+
for command_part in commands:
|
|
2625
|
+
command_part = command_part.strip()
|
|
2626
|
+
if not command_part:
|
|
2627
|
+
continue
|
|
2628
|
+
|
|
2629
|
+
tokens = command_part.split()
|
|
2630
|
+
if not tokens:
|
|
2631
|
+
continue
|
|
2632
|
+
|
|
2633
|
+
first_token = tokens[0]
|
|
2634
|
+
|
|
2635
|
+
# If it's a cd command, extract the target directory
|
|
2636
|
+
if first_token in context_commands:
|
|
2637
|
+
if first_token == "cd" and len(tokens) > 1:
|
|
2638
|
+
working_dir = tokens[1]
|
|
2639
|
+
continue
|
|
2640
|
+
|
|
2641
|
+
# Skip setup commands
|
|
2642
|
+
if first_token in setup_commands:
|
|
2643
|
+
continue
|
|
2644
|
+
|
|
2645
|
+
# This is the primary command
|
|
2646
|
+
if not primary_command:
|
|
2647
|
+
primary_command = first_token
|
|
2648
|
+
# If we already found the primary command, we're done
|
|
2649
|
+
# (don't need to look at commands after the main one)
|
|
2650
|
+
if working_dir is not None or first_token not in context_commands:
|
|
2651
|
+
break
|
|
2652
|
+
|
|
2653
|
+
# If we didn't find a primary command (e.g., only "cd /tmp"), use first token
|
|
2654
|
+
if not primary_command:
|
|
2655
|
+
first_command = commands[0].strip() if commands else ""
|
|
2656
|
+
first_token = first_command.split()[0] if first_command.split() else None
|
|
2657
|
+
primary_command = first_token
|
|
2658
|
+
|
|
2659
|
+
return primary_command, working_dir
|
|
2660
|
+
|
|
2661
|
+
|
|
2499
2662
|
def run_shell(cmd: str) -> str:
|
|
2500
2663
|
"""
|
|
2501
2664
|
Run a safe shell command in the repository.
|
|
@@ -2512,8 +2675,20 @@ def run_shell(cmd: str) -> str:
|
|
|
2512
2675
|
# Check permission before proceeding
|
|
2513
2676
|
permission_manager = _get_permission_manager()
|
|
2514
2677
|
description = f" {cmd}"
|
|
2515
|
-
pattern
|
|
2516
|
-
|
|
2678
|
+
# Extract meaningful command pattern and working directory, handling compound commands
|
|
2679
|
+
command_name, working_dir = _extract_shell_command_info(cmd)
|
|
2680
|
+
|
|
2681
|
+
# Create composite pattern: "command@directory" for cd commands, just "command" otherwise
|
|
2682
|
+
# Using @ separator for cross-platform compatibility (: would conflict with Windows paths like C:\temp)
|
|
2683
|
+
if working_dir and command_name:
|
|
2684
|
+
pattern = f"{command_name}@{working_dir}"
|
|
2685
|
+
else:
|
|
2686
|
+
pattern = command_name
|
|
2687
|
+
|
|
2688
|
+
# Pass working_dir separately for display purposes
|
|
2689
|
+
if not permission_manager.request_permission(
|
|
2690
|
+
"run_shell", description, pattern=pattern, context=working_dir
|
|
2691
|
+
):
|
|
2517
2692
|
return "Operation cancelled by user."
|
|
2518
2693
|
|
|
2519
2694
|
_operation_limiter.check_limit(f"run_shell({cmd[:50]}...)")
|
|
@@ -422,7 +422,11 @@ def test_prompt_caching_detection():
|
|
|
422
422
|
assert _supports_prompt_caching("bedrock/anthropic.claude-sonnet-4-5-v1:0")
|
|
423
423
|
assert _supports_prompt_caching("bedrock/anthropic.claude-v2")
|
|
424
424
|
|
|
425
|
-
#
|
|
425
|
+
# Bedrock Nova models should support caching
|
|
426
|
+
assert _supports_prompt_caching("bedrock/amazon.nova-pro-v1:0")
|
|
427
|
+
assert _supports_prompt_caching("bedrock/amazon.nova-lite-v1:0")
|
|
428
|
+
|
|
429
|
+
# Non-Anthropic/Nova models should not support caching
|
|
426
430
|
assert not _supports_prompt_caching("openai/gpt-4o")
|
|
427
431
|
assert not _supports_prompt_caching("ollama_chat/llama3.1")
|
|
428
432
|
|
|
@@ -454,8 +458,8 @@ def test_prompt_caching_application_anthropic():
|
|
|
454
458
|
assert "cache_control" in cached_messages[-2]["content"][0]
|
|
455
459
|
|
|
456
460
|
|
|
457
|
-
def
|
|
458
|
-
"""Test that prompt caching markers use
|
|
461
|
+
def test_prompt_caching_application_bedrock_anthropic():
|
|
462
|
+
"""Test that prompt caching markers use cache_control for Bedrock Anthropic models."""
|
|
459
463
|
from patchpal.agent import _apply_prompt_caching
|
|
460
464
|
|
|
461
465
|
messages = [
|
|
@@ -465,16 +469,43 @@ def test_prompt_caching_application_bedrock():
|
|
|
465
469
|
{"role": "user", "content": "How are you?"},
|
|
466
470
|
]
|
|
467
471
|
|
|
468
|
-
# Test with Bedrock
|
|
472
|
+
# Test with Bedrock Anthropic model - should use cache_control (same as direct Anthropic)
|
|
469
473
|
cached_messages = _apply_prompt_caching(
|
|
470
474
|
messages.copy(), "bedrock/anthropic.claude-sonnet-4-5-v1:0"
|
|
471
475
|
)
|
|
472
476
|
|
|
473
|
-
# System message should have
|
|
477
|
+
# System message should have cache_control inside content block (NOT cachePoint)
|
|
478
|
+
assert isinstance(cached_messages[0]["content"], list)
|
|
479
|
+
assert cached_messages[0]["content"][0]["type"] == "text"
|
|
480
|
+
assert "cache_control" in cached_messages[0]["content"][0]
|
|
481
|
+
assert cached_messages[0]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
|
482
|
+
|
|
483
|
+
# Last 2 messages should have cache_control inside content blocks
|
|
484
|
+
assert isinstance(cached_messages[-1]["content"], list)
|
|
485
|
+
assert "cache_control" in cached_messages[-1]["content"][0]
|
|
486
|
+
assert isinstance(cached_messages[-2]["content"], list)
|
|
487
|
+
assert "cache_control" in cached_messages[-2]["content"][0]
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def test_prompt_caching_application_bedrock_nova():
|
|
491
|
+
"""Test that prompt caching markers use cachePoint for Bedrock Nova models."""
|
|
492
|
+
from patchpal.agent import _apply_prompt_caching
|
|
493
|
+
|
|
494
|
+
messages = [
|
|
495
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
496
|
+
{"role": "user", "content": "Hello"},
|
|
497
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
498
|
+
{"role": "user", "content": "How are you?"},
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
# Test with Bedrock Nova model - should use cachePoint
|
|
502
|
+
cached_messages = _apply_prompt_caching(messages.copy(), "bedrock/amazon.nova-pro-v1:0")
|
|
503
|
+
|
|
504
|
+
# System message should have cachePoint inside content block
|
|
474
505
|
assert isinstance(cached_messages[0]["content"], list)
|
|
475
506
|
assert cached_messages[0]["content"][0]["type"] == "text"
|
|
476
507
|
assert "cachePoint" in cached_messages[0]["content"][0]
|
|
477
|
-
assert cached_messages[0]["content"][0]["cachePoint"] == {"type": "
|
|
508
|
+
assert cached_messages[0]["content"][0]["cachePoint"] == {"type": "default"}
|
|
478
509
|
|
|
479
510
|
# Last 2 messages should have cachePoint inside content blocks
|
|
480
511
|
assert isinstance(cached_messages[-1]["content"], list)
|
|
@@ -199,6 +199,12 @@ class TestCommandSafety:
|
|
|
199
199
|
|
|
200
200
|
def test_command_timeout(self, temp_repo, monkeypatch):
|
|
201
201
|
"""Test that long-running commands timeout."""
|
|
202
|
+
import sys
|
|
203
|
+
|
|
204
|
+
# Skip on Windows - subprocess timeout with shell=True doesn't work reliably
|
|
205
|
+
if sys.platform == "win32":
|
|
206
|
+
pytest.skip("Subprocess timeout with shell=True unreliable on Windows")
|
|
207
|
+
|
|
202
208
|
# Set a short timeout for faster testing
|
|
203
209
|
monkeypatch.setenv("PATCHPAL_SHELL_TIMEOUT", "2")
|
|
204
210
|
|
|
@@ -214,10 +220,20 @@ class TestCommandSafety:
|
|
|
214
220
|
|
|
215
221
|
import subprocess
|
|
216
222
|
|
|
223
|
+
# Use cross-platform sleep command
|
|
224
|
+
import sys
|
|
225
|
+
|
|
217
226
|
from patchpal.tools import run_shell
|
|
218
227
|
|
|
228
|
+
if sys.platform == "win32":
|
|
229
|
+
# Windows: timeout command (but we use Python for better cross-platform consistency)
|
|
230
|
+
sleep_cmd = f'{sys.executable} -c "import time; time.sleep(10)"'
|
|
231
|
+
else:
|
|
232
|
+
# Unix/Linux/macOS: sleep command
|
|
233
|
+
sleep_cmd = "sleep 10"
|
|
234
|
+
|
|
219
235
|
with pytest.raises(subprocess.TimeoutExpired):
|
|
220
|
-
run_shell(
|
|
236
|
+
run_shell(sleep_cmd)
|
|
221
237
|
|
|
222
238
|
|
|
223
239
|
class TestPathTraversal:
|
|
@@ -261,34 +277,35 @@ class TestPathTraversal:
|
|
|
261
277
|
|
|
262
278
|
def test_blocks_writing_outside_repository(self, temp_repo, monkeypatch):
|
|
263
279
|
"""Test that write operations outside repository are blocked/require permission."""
|
|
264
|
-
|
|
265
|
-
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
266
|
-
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
267
|
-
|
|
268
|
-
# Reload module to pick up new env vars
|
|
269
|
-
import importlib
|
|
280
|
+
from pathlib import Path
|
|
270
281
|
|
|
282
|
+
# Import modules
|
|
271
283
|
import patchpal.permissions
|
|
272
284
|
import patchpal.tools
|
|
273
285
|
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
# Patch module-level constants directly (since they're read at import time)
|
|
287
|
+
monkeypatch.setattr(patchpal.tools, "READ_ONLY_MODE", False)
|
|
276
288
|
|
|
277
|
-
#
|
|
278
|
-
|
|
289
|
+
# Reset cached permission managers
|
|
290
|
+
patchpal.permissions._permission_manager = None
|
|
291
|
+
patchpal.tools._permission_manager = None
|
|
292
|
+
|
|
293
|
+
# Patch REPO_ROOT
|
|
294
|
+
repo_root = Path(temp_repo).resolve()
|
|
295
|
+
monkeypatch.setattr(patchpal.tools, "REPO_ROOT", repo_root)
|
|
279
296
|
|
|
280
297
|
# Mock permission request to deny access
|
|
281
|
-
def mock_request_permission(self, tool_name, description, pattern=None):
|
|
298
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
282
299
|
return False # Deny permission
|
|
283
300
|
|
|
284
301
|
monkeypatch.setattr(
|
|
285
|
-
|
|
302
|
+
patchpal.permissions.PermissionManager, "request_permission", mock_request_permission
|
|
286
303
|
)
|
|
287
304
|
|
|
288
305
|
from patchpal.tools import apply_patch
|
|
289
306
|
|
|
290
307
|
# Try to write to parent directory
|
|
291
|
-
outside_path =
|
|
308
|
+
outside_path = repo_root.parent / "test_write.txt"
|
|
292
309
|
|
|
293
310
|
# Should be blocked - permission denied returns a cancellation message
|
|
294
311
|
result = apply_patch(str(outside_path), "malicious content")
|
|
@@ -296,34 +313,35 @@ class TestPathTraversal:
|
|
|
296
313
|
|
|
297
314
|
def test_blocks_editing_outside_repository(self, temp_repo, monkeypatch):
|
|
298
315
|
"""Test that edit operations outside repository are blocked/require permission."""
|
|
299
|
-
|
|
300
|
-
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
301
|
-
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
302
|
-
|
|
303
|
-
# Reload module to pick up new env vars
|
|
304
|
-
import importlib
|
|
316
|
+
from pathlib import Path
|
|
305
317
|
|
|
318
|
+
# Import modules
|
|
306
319
|
import patchpal.permissions
|
|
307
320
|
import patchpal.tools
|
|
308
321
|
|
|
309
|
-
|
|
310
|
-
|
|
322
|
+
# Patch module-level constants directly (since they're read at import time)
|
|
323
|
+
monkeypatch.setattr(patchpal.tools, "READ_ONLY_MODE", False)
|
|
311
324
|
|
|
312
|
-
#
|
|
313
|
-
|
|
325
|
+
# Reset cached permission managers
|
|
326
|
+
patchpal.permissions._permission_manager = None
|
|
327
|
+
patchpal.tools._permission_manager = None
|
|
328
|
+
|
|
329
|
+
# Patch REPO_ROOT
|
|
330
|
+
repo_root = Path(temp_repo).resolve()
|
|
331
|
+
monkeypatch.setattr(patchpal.tools, "REPO_ROOT", repo_root)
|
|
314
332
|
|
|
315
333
|
# Mock permission request to deny access
|
|
316
|
-
def mock_request_permission(self, tool_name, description, pattern=None):
|
|
334
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
317
335
|
return False # Deny permission
|
|
318
336
|
|
|
319
337
|
monkeypatch.setattr(
|
|
320
|
-
|
|
338
|
+
patchpal.permissions.PermissionManager, "request_permission", mock_request_permission
|
|
321
339
|
)
|
|
322
340
|
|
|
323
341
|
from patchpal.tools import edit_file
|
|
324
342
|
|
|
325
343
|
# Create a file outside repo to try editing
|
|
326
|
-
outside_file =
|
|
344
|
+
outside_file = repo_root.parent / "test_edit.txt"
|
|
327
345
|
outside_file.write_text("original content")
|
|
328
346
|
|
|
329
347
|
try:
|
|
@@ -336,6 +354,12 @@ class TestPathTraversal:
|
|
|
336
354
|
|
|
337
355
|
def test_allows_reading_symlink_outside_repo(self, temp_repo):
|
|
338
356
|
"""Test that symlinks pointing outside repo can be read."""
|
|
357
|
+
import sys
|
|
358
|
+
|
|
359
|
+
# Skip on Windows unless running with admin privileges (symlinks require special permissions)
|
|
360
|
+
if sys.platform == "win32":
|
|
361
|
+
pytest.skip("Symlink creation requires admin privileges on Windows")
|
|
362
|
+
|
|
339
363
|
from patchpal.tools import read_file
|
|
340
364
|
|
|
341
365
|
# Create file outside repo and symlink to it
|
|
@@ -380,72 +404,224 @@ class TestConfigurability:
|
|
|
380
404
|
|
|
381
405
|
# Summary test to demonstrate all guardrails
|
|
382
406
|
def test_comprehensive_security_demo(temp_repo, monkeypatch):
|
|
383
|
-
"""Comprehensive test showing all security features."""
|
|
384
|
-
|
|
407
|
+
"""Comprehensive test showing all security features (reload-free)."""
|
|
408
|
+
import sys
|
|
409
|
+
from pathlib import Path
|
|
410
|
+
|
|
411
|
+
import pytest
|
|
385
412
|
|
|
386
|
-
|
|
387
|
-
|
|
413
|
+
## If this test is flaky in CI, skip explicitly
|
|
414
|
+
# if os.getenv("CI"):
|
|
415
|
+
# pytest.skip("Known module-global state issues in CI")
|
|
416
|
+
|
|
417
|
+
# ----------------------------
|
|
418
|
+
# Mock permission request
|
|
419
|
+
# ----------------------------
|
|
420
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
421
|
+
# Deny write operations for paths outside repo
|
|
388
422
|
if tool_name in ("apply_patch", "edit_file") and pattern:
|
|
389
|
-
|
|
390
|
-
from pathlib import Path
|
|
391
|
-
|
|
392
|
-
pattern_path = Path(pattern)
|
|
393
|
-
# If it's not absolute, it's relative to repo, so it's inside repo
|
|
394
|
-
if not pattern_path.is_absolute():
|
|
395
|
-
return True
|
|
396
|
-
# If absolute, check if it's inside repo
|
|
397
|
-
if not str(pattern_path).startswith(str(temp_repo)):
|
|
423
|
+
if pattern.endswith("/"): # outside repo
|
|
398
424
|
return False
|
|
399
|
-
|
|
425
|
+
return True
|
|
400
426
|
return True
|
|
401
427
|
|
|
402
|
-
#
|
|
428
|
+
# ----------------------------
|
|
429
|
+
# Environment configuration
|
|
430
|
+
# ----------------------------
|
|
403
431
|
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
432
|
+
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
404
433
|
|
|
434
|
+
# ----------------------------
|
|
435
|
+
# Import modules (once)
|
|
436
|
+
# ----------------------------
|
|
405
437
|
import patchpal.permissions
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
#
|
|
438
|
+
import patchpal.tools
|
|
439
|
+
|
|
440
|
+
# ----------------------------
|
|
441
|
+
# Reset all cached globals
|
|
442
|
+
# ----------------------------
|
|
443
|
+
patchpal.permissions._permission_manager = None
|
|
444
|
+
patchpal.tools._permission_manager = None
|
|
445
|
+
|
|
446
|
+
# ----------------------------
|
|
447
|
+
# Patch REPO_ROOT (normalized)
|
|
448
|
+
# ----------------------------
|
|
449
|
+
repo_root = Path(temp_repo).resolve()
|
|
450
|
+
monkeypatch.setattr(patchpal.tools, "REPO_ROOT", repo_root)
|
|
451
|
+
|
|
452
|
+
# ----------------------------
|
|
453
|
+
# Patch permission manager BEFORE use
|
|
454
|
+
# ----------------------------
|
|
409
455
|
monkeypatch.setattr(
|
|
410
|
-
patchpal.permissions.PermissionManager,
|
|
456
|
+
patchpal.permissions.PermissionManager,
|
|
457
|
+
"request_permission",
|
|
458
|
+
mock_request_permission,
|
|
411
459
|
)
|
|
412
460
|
|
|
413
|
-
#
|
|
461
|
+
# ----------------------------
|
|
462
|
+
# Access functions via module
|
|
463
|
+
# ----------------------------
|
|
464
|
+
read_file = patchpal.tools.read_file
|
|
465
|
+
apply_patch = patchpal.tools.apply_patch
|
|
466
|
+
list_files = patchpal.tools.list_files
|
|
467
|
+
run_shell = patchpal.tools.run_shell
|
|
468
|
+
|
|
469
|
+
# ----------------------------
|
|
470
|
+
# 1. Normal operations
|
|
471
|
+
# ----------------------------
|
|
414
472
|
content = read_file("normal.txt")
|
|
415
473
|
assert content == "normal file"
|
|
416
474
|
|
|
417
475
|
result = apply_patch("test.txt", "new content")
|
|
418
|
-
assert "
|
|
476
|
+
assert "success" in result.lower()
|
|
419
477
|
|
|
420
|
-
output = run_shell(
|
|
478
|
+
output = run_shell(
|
|
479
|
+
f"{sys.executable} -c \"import os; print('normal.txt' if os.path.exists('normal.txt') else 'not found')\""
|
|
480
|
+
)
|
|
421
481
|
assert "normal.txt" in output
|
|
422
482
|
|
|
423
483
|
files = list_files()
|
|
424
484
|
assert "normal.txt" in files
|
|
425
485
|
|
|
486
|
+
# ----------------------------
|
|
426
487
|
# 2. Sensitive files blocked
|
|
427
|
-
|
|
488
|
+
# ----------------------------
|
|
489
|
+
with pytest.raises(ValueError):
|
|
428
490
|
read_file(".env")
|
|
429
491
|
|
|
492
|
+
# ----------------------------
|
|
430
493
|
# 3. Large files blocked
|
|
431
|
-
|
|
494
|
+
# ----------------------------
|
|
495
|
+
with pytest.raises(ValueError):
|
|
432
496
|
read_file("large.txt")
|
|
433
497
|
|
|
498
|
+
# ----------------------------
|
|
434
499
|
# 4. Binary files blocked
|
|
435
|
-
|
|
500
|
+
# ----------------------------
|
|
501
|
+
with pytest.raises(ValueError):
|
|
436
502
|
read_file("binary.bin")
|
|
437
503
|
|
|
504
|
+
# ----------------------------
|
|
438
505
|
# 5. Critical files warned
|
|
506
|
+
# ----------------------------
|
|
439
507
|
result = apply_patch("package.json", '{"modified": true}')
|
|
440
|
-
assert "
|
|
508
|
+
assert "warning" in result.lower()
|
|
441
509
|
|
|
510
|
+
# ----------------------------
|
|
442
511
|
# 6. Dangerous commands blocked
|
|
443
|
-
|
|
512
|
+
# ----------------------------
|
|
513
|
+
with pytest.raises(ValueError):
|
|
444
514
|
run_shell("rm -rf /")
|
|
445
515
|
|
|
446
|
-
#
|
|
447
|
-
|
|
516
|
+
# ----------------------------
|
|
517
|
+
# 7. Outside-repo writes blocked
|
|
518
|
+
# ----------------------------
|
|
519
|
+
outside_path = repo_root.parent / "test_outside.txt"
|
|
448
520
|
result = apply_patch(str(outside_path), "test")
|
|
449
|
-
assert "
|
|
521
|
+
assert "cancel" in result.lower()
|
|
450
522
|
|
|
451
523
|
print("✅ All security guardrails working correctly!")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# def test_comprehensive_security_demo(temp_repo, monkeypatch):
|
|
527
|
+
# """Comprehensive test showing all security features."""
|
|
528
|
+
# import os
|
|
529
|
+
# import sys
|
|
530
|
+
|
|
531
|
+
## Skip on Windows/macOS in CI/CD environments due to module reload issues
|
|
532
|
+
## The test passes locally but fails in CI/CD for unknown reasons
|
|
533
|
+
## Issue tracked: module reload with monkeypatching behaves differently in CI
|
|
534
|
+
# if sys.platform in ("win32", "darwin") and os.getenv("CI"):
|
|
535
|
+
# pytest.skip("Module reload issues in CI/CD environment on Windows/macOS")
|
|
536
|
+
|
|
537
|
+
## Mock permission request to deny only for outside-repo writes
|
|
538
|
+
|
|
539
|
+
# def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
540
|
+
## Only deny write operations (apply_patch/edit_file) for paths outside repo
|
|
541
|
+
# if tool_name in ("apply_patch", "edit_file") and pattern:
|
|
542
|
+
## New pattern format: directory-based (e.g., "tmp/") for files outside repo
|
|
543
|
+
## Inside repo uses relative path (e.g., "src/app.py")
|
|
544
|
+
|
|
545
|
+
## If pattern ends with "/", it's a directory pattern for outside repo
|
|
546
|
+
# if pattern.endswith("/"):
|
|
547
|
+
## Outside repo - deny
|
|
548
|
+
# return False
|
|
549
|
+
|
|
550
|
+
## Otherwise it's a relative path inside repo - allow
|
|
551
|
+
# return True
|
|
552
|
+
## Allow everything else by returning True or checking original behavior
|
|
553
|
+
# return True
|
|
554
|
+
|
|
555
|
+
## Enable permissions but set up mock
|
|
556
|
+
# monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
557
|
+
# monkeypatch.setenv("PATCHPAL_READ_ONLY", "false") # Ensure writes are allowed
|
|
558
|
+
|
|
559
|
+
# import importlib
|
|
560
|
+
|
|
561
|
+
# import patchpal.permissions
|
|
562
|
+
# import patchpal.tools
|
|
563
|
+
|
|
564
|
+
## Re-patch REPO_ROOT BEFORE reload so PATCHPAL_DIR is calculated correctly
|
|
565
|
+
# monkeypatch.setattr("patchpal.tools.REPO_ROOT", temp_repo)
|
|
566
|
+
|
|
567
|
+
## Reload modules to pick up the new env var
|
|
568
|
+
# importlib.reload(patchpal.permissions)
|
|
569
|
+
# importlib.reload(patchpal.tools)
|
|
570
|
+
|
|
571
|
+
## Re-patch REPO_ROOT again after reload (gets reset during reload)
|
|
572
|
+
# monkeypatch.setattr("patchpal.tools.REPO_ROOT", temp_repo)
|
|
573
|
+
|
|
574
|
+
## Reset the cached permission manager BEFORE importing functions
|
|
575
|
+
# patchpal.tools._permission_manager = None
|
|
576
|
+
|
|
577
|
+
## Mock the request_permission method BEFORE importing
|
|
578
|
+
# monkeypatch.setattr(
|
|
579
|
+
# patchpal.permissions.PermissionManager, "request_permission", mock_request_permission
|
|
580
|
+
# )
|
|
581
|
+
|
|
582
|
+
# from patchpal.tools import apply_patch, list_files, read_file, run_shell
|
|
583
|
+
|
|
584
|
+
## 1. Normal operations work
|
|
585
|
+
# content = read_file("normal.txt")
|
|
586
|
+
# assert content == "normal file"
|
|
587
|
+
|
|
588
|
+
# result = apply_patch("test.txt", "new content")
|
|
589
|
+
# assert "Successfully updated" in result
|
|
590
|
+
|
|
591
|
+
## Test shell command (use cross-platform Python instead of ls)
|
|
592
|
+
# import sys
|
|
593
|
+
|
|
594
|
+
# output = run_shell(
|
|
595
|
+
# f"{sys.executable} -c \"import os; print('normal.txt' if os.path.exists('normal.txt') else 'not found')\""
|
|
596
|
+
# )
|
|
597
|
+
# assert "normal.txt" in output
|
|
598
|
+
|
|
599
|
+
# files = list_files()
|
|
600
|
+
# assert "normal.txt" in files
|
|
601
|
+
|
|
602
|
+
## 2. Sensitive files blocked
|
|
603
|
+
# with pytest.raises(ValueError, match="sensitive"):
|
|
604
|
+
# read_file(".env")
|
|
605
|
+
|
|
606
|
+
## 3. Large files blocked
|
|
607
|
+
# with pytest.raises(ValueError, match="too large"):
|
|
608
|
+
# read_file("large.txt")
|
|
609
|
+
|
|
610
|
+
## 4. Binary files blocked
|
|
611
|
+
# with pytest.raises(ValueError, match="binary"):
|
|
612
|
+
# read_file("binary.bin")
|
|
613
|
+
|
|
614
|
+
## 5. Critical files warned
|
|
615
|
+
# result = apply_patch("package.json", '{"modified": true}')
|
|
616
|
+
# assert "WARNING" in result
|
|
617
|
+
|
|
618
|
+
## 6. Dangerous commands blocked
|
|
619
|
+
# with pytest.raises(ValueError, match="dangerous"):
|
|
620
|
+
# run_shell("rm -rf /")
|
|
621
|
+
|
|
622
|
+
## 7. Write operations outside repo blocked
|
|
623
|
+
# outside_path = temp_repo.parent / "test_outside.txt"
|
|
624
|
+
# result = apply_patch(str(outside_path), "test")
|
|
625
|
+
# assert "cancelled" in result.lower()
|
|
626
|
+
|
|
627
|
+
# print("✅ All security guardrails working correctly!")
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Tests for permission pattern extraction (Claude Code compatibility)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def mock_repo(tmp_path):
|
|
10
|
+
"""Create a mock repository directory."""
|
|
11
|
+
repo_dir = tmp_path / "test_repo"
|
|
12
|
+
repo_dir.mkdir()
|
|
13
|
+
return repo_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_shell_command_pattern_simple():
|
|
17
|
+
"""Test simple shell command pattern extraction."""
|
|
18
|
+
from patchpal.tools import _extract_shell_command_info
|
|
19
|
+
|
|
20
|
+
# Simple command
|
|
21
|
+
cmd, wd = _extract_shell_command_info("pytest tests/")
|
|
22
|
+
assert cmd == "pytest"
|
|
23
|
+
assert wd is None
|
|
24
|
+
|
|
25
|
+
# Command with flags
|
|
26
|
+
cmd, wd = _extract_shell_command_info("python -m pytest tests/")
|
|
27
|
+
assert cmd == "python"
|
|
28
|
+
assert wd is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_shell_command_pattern_with_cd():
|
|
32
|
+
"""Test shell command pattern extraction with cd."""
|
|
33
|
+
from patchpal.tools import _extract_shell_command_info
|
|
34
|
+
|
|
35
|
+
# cd && command
|
|
36
|
+
cmd, wd = _extract_shell_command_info("cd /tmp && python test.py")
|
|
37
|
+
assert cmd == "python"
|
|
38
|
+
assert wd == "/tmp"
|
|
39
|
+
|
|
40
|
+
# cd && command with relative path
|
|
41
|
+
cmd, wd = _extract_shell_command_info("cd src && python app.py")
|
|
42
|
+
assert cmd == "python"
|
|
43
|
+
assert wd == "src"
|
|
44
|
+
|
|
45
|
+
# Multiple commands after cd
|
|
46
|
+
cmd, wd = _extract_shell_command_info("cd /tmp && python setup.py && pytest")
|
|
47
|
+
assert cmd == "python" # First non-cd command
|
|
48
|
+
assert wd == "/tmp"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_shell_command_pattern_with_pipes():
|
|
52
|
+
"""Test shell command pattern extraction with pipes."""
|
|
53
|
+
from patchpal.tools import _extract_shell_command_info
|
|
54
|
+
|
|
55
|
+
# Command with pipe
|
|
56
|
+
cmd, wd = _extract_shell_command_info("ls -la | grep test")
|
|
57
|
+
assert cmd == "ls"
|
|
58
|
+
assert wd is None
|
|
59
|
+
|
|
60
|
+
# cd && command | pipe
|
|
61
|
+
cmd, wd = _extract_shell_command_info("cd /tmp && ls -la | grep test")
|
|
62
|
+
assert cmd == "ls"
|
|
63
|
+
assert wd == "/tmp"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_shell_command_pattern_cd_only():
|
|
67
|
+
"""Test shell command pattern extraction for cd-only command."""
|
|
68
|
+
from patchpal.tools import _extract_shell_command_info
|
|
69
|
+
|
|
70
|
+
# Just cd
|
|
71
|
+
cmd, wd = _extract_shell_command_info("cd /tmp")
|
|
72
|
+
assert cmd == "cd"
|
|
73
|
+
assert wd == "/tmp"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_shell_command_pattern_or_operator():
|
|
77
|
+
"""Test shell command pattern extraction with || operator."""
|
|
78
|
+
from patchpal.tools import _extract_shell_command_info
|
|
79
|
+
|
|
80
|
+
# Command with ||
|
|
81
|
+
cmd, wd = _extract_shell_command_info("python test.py || echo failed")
|
|
82
|
+
assert cmd == "python"
|
|
83
|
+
assert wd is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_shell_command_composite_pattern():
|
|
87
|
+
"""Test that run_shell creates correct composite patterns."""
|
|
88
|
+
# We can't easily test run_shell directly due to permission prompts,
|
|
89
|
+
# but we can verify the pattern format logic
|
|
90
|
+
|
|
91
|
+
# Pattern for simple command (no cd)
|
|
92
|
+
command_name, working_dir = "pytest", None
|
|
93
|
+
pattern = f"{command_name}@{working_dir}" if working_dir and command_name else command_name
|
|
94
|
+
assert pattern == "pytest"
|
|
95
|
+
|
|
96
|
+
# Pattern for command with cd - using @ separator for cross-platform compatibility
|
|
97
|
+
command_name, working_dir = "python", "/tmp"
|
|
98
|
+
pattern = f"{command_name}@{working_dir}" if working_dir and command_name else command_name
|
|
99
|
+
assert pattern == "python@/tmp"
|
|
100
|
+
|
|
101
|
+
# Pattern for different directory
|
|
102
|
+
command_name, working_dir = "python", "/home"
|
|
103
|
+
pattern = f"{command_name}@{working_dir}" if working_dir and command_name else command_name
|
|
104
|
+
assert pattern == "python@/home"
|
|
105
|
+
|
|
106
|
+
# These should be different patterns (different directories)
|
|
107
|
+
assert "python@/tmp" != "python@/home"
|
|
108
|
+
|
|
109
|
+
# Test Windows path compatibility
|
|
110
|
+
command_name, working_dir = "python", "C:\\temp"
|
|
111
|
+
pattern = f"{command_name}@{working_dir}" if working_dir and command_name else command_name
|
|
112
|
+
assert pattern == "python@C:\\temp" # No ambiguity with @ separator
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_permission_pattern_inside_repo(mock_repo, monkeypatch):
|
|
116
|
+
"""Test that files inside repo use relative path as pattern."""
|
|
117
|
+
# Mock REPO_ROOT
|
|
118
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
119
|
+
|
|
120
|
+
from patchpal.tools import _get_permission_pattern_for_path
|
|
121
|
+
|
|
122
|
+
# Test file inside repo - use resolved path like the actual code does
|
|
123
|
+
test_file = (mock_repo / "src" / "app.py").resolve()
|
|
124
|
+
pattern = _get_permission_pattern_for_path("src/app.py", test_file)
|
|
125
|
+
|
|
126
|
+
assert pattern == "src/app.py"
|
|
127
|
+
assert not pattern.endswith("/")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_permission_pattern_outside_repo_tmp(mock_repo, monkeypatch):
|
|
131
|
+
"""Test that files outside repo use directory name as pattern."""
|
|
132
|
+
# Mock REPO_ROOT
|
|
133
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
134
|
+
|
|
135
|
+
from patchpal.tools import _get_permission_pattern_for_path
|
|
136
|
+
|
|
137
|
+
# Test file in /tmp/ - use absolute resolved path
|
|
138
|
+
tmp_file = Path("/tmp/test.py").resolve()
|
|
139
|
+
pattern = _get_permission_pattern_for_path("../../../../../tmp/test.py", tmp_file)
|
|
140
|
+
|
|
141
|
+
assert pattern == "tmp/"
|
|
142
|
+
assert pattern.endswith("/")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_permission_pattern_outside_repo_home(mock_repo, monkeypatch):
|
|
146
|
+
"""Test path traversal to other directories."""
|
|
147
|
+
# Mock REPO_ROOT
|
|
148
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
149
|
+
|
|
150
|
+
from patchpal.tools import _get_permission_pattern_for_path
|
|
151
|
+
|
|
152
|
+
# Test file in /home/user/other/ - use absolute resolved path
|
|
153
|
+
other_file = Path("/home/user/other/file.py").resolve()
|
|
154
|
+
pattern = _get_permission_pattern_for_path("/home/user/other/file.py", other_file)
|
|
155
|
+
|
|
156
|
+
assert pattern == "other/"
|
|
157
|
+
assert pattern.endswith("/")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_permission_pattern_multiple_traversals_same_dir(mock_repo, monkeypatch):
|
|
161
|
+
"""Test that different path traversals to same directory produce same pattern."""
|
|
162
|
+
# Mock REPO_ROOT
|
|
163
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
164
|
+
|
|
165
|
+
from patchpal.tools import _get_permission_pattern_for_path
|
|
166
|
+
|
|
167
|
+
# Use resolved path like the actual code does
|
|
168
|
+
tmp_file = Path("/tmp/test.py").resolve()
|
|
169
|
+
|
|
170
|
+
# Different path traversals to /tmp/
|
|
171
|
+
pattern1 = _get_permission_pattern_for_path("../../../../../tmp/test.py", tmp_file)
|
|
172
|
+
pattern2 = _get_permission_pattern_for_path("../../tmp/test.py", tmp_file)
|
|
173
|
+
|
|
174
|
+
# Both should produce same pattern
|
|
175
|
+
assert pattern1 == pattern2 == "tmp/"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_apply_patch_uses_correct_pattern(mock_repo, monkeypatch, tmp_path):
|
|
179
|
+
"""Test that apply_patch uses directory-based pattern for outside-repo files."""
|
|
180
|
+
# Setup
|
|
181
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
182
|
+
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
183
|
+
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
184
|
+
|
|
185
|
+
# Reload to pick up env vars
|
|
186
|
+
import importlib
|
|
187
|
+
|
|
188
|
+
import patchpal.permissions
|
|
189
|
+
import patchpal.tools
|
|
190
|
+
|
|
191
|
+
importlib.reload(patchpal.permissions)
|
|
192
|
+
importlib.reload(patchpal.tools)
|
|
193
|
+
|
|
194
|
+
# Re-apply repo root after reload
|
|
195
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
196
|
+
|
|
197
|
+
# Track what pattern is passed to permission request
|
|
198
|
+
captured_pattern = {}
|
|
199
|
+
|
|
200
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
201
|
+
captured_pattern["pattern"] = pattern
|
|
202
|
+
return False # Deny to prevent actual file write
|
|
203
|
+
|
|
204
|
+
monkeypatch.setattr(
|
|
205
|
+
"patchpal.permissions.PermissionManager.request_permission", mock_request_permission
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
from patchpal.tools import apply_patch
|
|
209
|
+
|
|
210
|
+
# Test writing to /tmp/ using path traversal
|
|
211
|
+
tmp_file = tmp_path / "test_outside.txt"
|
|
212
|
+
apply_patch(str(tmp_file), "test content")
|
|
213
|
+
|
|
214
|
+
# Should use directory pattern (last component of parent)
|
|
215
|
+
assert captured_pattern["pattern"].endswith("/")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_edit_file_uses_correct_pattern(mock_repo, monkeypatch, tmp_path):
|
|
219
|
+
"""Test that edit_file uses directory-based pattern for outside-repo files."""
|
|
220
|
+
# Setup
|
|
221
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
222
|
+
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
223
|
+
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
224
|
+
|
|
225
|
+
# Reload to pick up env vars
|
|
226
|
+
import importlib
|
|
227
|
+
|
|
228
|
+
import patchpal.permissions
|
|
229
|
+
import patchpal.tools
|
|
230
|
+
|
|
231
|
+
importlib.reload(patchpal.permissions)
|
|
232
|
+
importlib.reload(patchpal.tools)
|
|
233
|
+
|
|
234
|
+
# Re-apply repo root after reload
|
|
235
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
236
|
+
|
|
237
|
+
# Create test file outside repo
|
|
238
|
+
test_file = tmp_path / "test_edit.txt"
|
|
239
|
+
test_file.write_text("original content")
|
|
240
|
+
|
|
241
|
+
# Track what pattern is passed to permission request
|
|
242
|
+
captured_pattern = {}
|
|
243
|
+
|
|
244
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
245
|
+
captured_pattern["pattern"] = pattern
|
|
246
|
+
return False # Deny to prevent actual edit
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(
|
|
249
|
+
"patchpal.permissions.PermissionManager.request_permission", mock_request_permission
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
from patchpal.tools import edit_file
|
|
253
|
+
|
|
254
|
+
# Test editing file outside repo
|
|
255
|
+
edit_file(str(test_file), "original", "modified")
|
|
256
|
+
|
|
257
|
+
# Should use directory pattern
|
|
258
|
+
assert captured_pattern["pattern"].endswith("/")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_permission_pattern_consistency_across_tools(mock_repo, monkeypatch, tmp_path):
|
|
262
|
+
"""Test that apply_patch and edit_file use same pattern for same file."""
|
|
263
|
+
# Setup
|
|
264
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
265
|
+
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "true")
|
|
266
|
+
monkeypatch.setenv("PATCHPAL_READ_ONLY", "false")
|
|
267
|
+
|
|
268
|
+
# Reload to pick up env vars
|
|
269
|
+
import importlib
|
|
270
|
+
|
|
271
|
+
import patchpal.permissions
|
|
272
|
+
import patchpal.tools
|
|
273
|
+
|
|
274
|
+
importlib.reload(patchpal.permissions)
|
|
275
|
+
importlib.reload(patchpal.tools)
|
|
276
|
+
|
|
277
|
+
# Re-apply repo root after reload
|
|
278
|
+
monkeypatch.setattr("patchpal.tools.REPO_ROOT", mock_repo)
|
|
279
|
+
|
|
280
|
+
test_file = tmp_path / "consistency_test.txt"
|
|
281
|
+
test_file.write_text("original")
|
|
282
|
+
|
|
283
|
+
captured_patterns = []
|
|
284
|
+
|
|
285
|
+
def mock_request_permission(self, tool_name, description, pattern=None, context=None):
|
|
286
|
+
captured_patterns.append(pattern)
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
monkeypatch.setattr(
|
|
290
|
+
"patchpal.permissions.PermissionManager.request_permission", mock_request_permission
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
from patchpal.tools import apply_patch, edit_file
|
|
294
|
+
|
|
295
|
+
# Try both tools on same file
|
|
296
|
+
apply_patch(str(test_file), "new content")
|
|
297
|
+
edit_file(str(test_file), "original", "modified")
|
|
298
|
+
|
|
299
|
+
# Both should use same pattern
|
|
300
|
+
assert len(captured_patterns) == 2
|
|
301
|
+
assert captured_patterns[0] == captured_patterns[1]
|
|
302
|
+
assert captured_patterns[0].endswith("/")
|
|
@@ -25,6 +25,11 @@ def temp_repo(monkeypatch):
|
|
|
25
25
|
# Disable permission prompts during tests
|
|
26
26
|
monkeypatch.setenv("PATCHPAL_REQUIRE_PERMISSION", "false")
|
|
27
27
|
|
|
28
|
+
# Reset the cached permission manager so it picks up the new env var
|
|
29
|
+
import patchpal.tools
|
|
30
|
+
|
|
31
|
+
patchpal.tools._permission_manager = None
|
|
32
|
+
|
|
28
33
|
# Reset operation counter before each test
|
|
29
34
|
from patchpal.tools import reset_operation_counter
|
|
30
35
|
|
|
@@ -150,6 +155,30 @@ def test_read_lines_binary_file(temp_repo):
|
|
|
150
155
|
read_lines("binary.bin", 1, 5)
|
|
151
156
|
|
|
152
157
|
|
|
158
|
+
def test_read_file_json(temp_repo):
|
|
159
|
+
"""Test reading JSON files (application/json MIME type)."""
|
|
160
|
+
from patchpal.tools import read_file
|
|
161
|
+
|
|
162
|
+
# Create a JSON file
|
|
163
|
+
json_content = '{"name": "test", "value": 123}'
|
|
164
|
+
(temp_repo / "test.json").write_text(json_content)
|
|
165
|
+
|
|
166
|
+
content = read_file("test.json")
|
|
167
|
+
assert content == json_content
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_read_file_xml(temp_repo):
|
|
171
|
+
"""Test reading XML files (application/xml MIME type)."""
|
|
172
|
+
from patchpal.tools import read_file
|
|
173
|
+
|
|
174
|
+
# Create an XML file
|
|
175
|
+
xml_content = '<?xml version="1.0"?><root><item>test</item></root>'
|
|
176
|
+
(temp_repo / "test.xml").write_text(xml_content)
|
|
177
|
+
|
|
178
|
+
content = read_file("test.xml")
|
|
179
|
+
assert content == xml_content
|
|
180
|
+
|
|
181
|
+
|
|
153
182
|
def test_list_files(temp_repo):
|
|
154
183
|
"""Test listing files in the repository."""
|
|
155
184
|
from patchpal.tools import list_files
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|