patchpal 0.24.0__tar.gz → 0.24.1__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.24.0/patchpal.egg-info → patchpal-0.24.1}/PKG-INFO +1 -1
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/__init__.py +1 -1
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/sandbox.py +38 -7
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/shell_tools.py +5 -1
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/web_tools.py +14 -2
- {patchpal-0.24.0 → patchpal-0.24.1/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_permissions.py +28 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_tools.py +38 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/LICENSE +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/MANIFEST.in +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/README.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/agent/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/agent/bedrock_profile_utils.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/agent/function_calling.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/agent/react.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/autopilot.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/interactive.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/mcp.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/cli/streaming.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/config.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/context.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/permissions.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/prompts/react_prompt.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/prompts/system_prompt.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/skills.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/audit.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/browser_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/code_analysis.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/common.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/definitions.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/file_reading.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/file_writing.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/find_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/grep_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/image_handler.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/mcp.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/repo_map.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/todo_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/tool_schema.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal/tools/user_interaction.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal.egg-info/SOURCES.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/pyproject.toml +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/setup.cfg +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_agent.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_cli.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_config_dynamic.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_context.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_custom_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_enabled_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_find_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_guardrails.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_image_blocking.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_maximum_security.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_mcp_config.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_memory.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_memory_locations.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_operational_safety.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_optional_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_react.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_reasoning_content.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_repo_map.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_simplified_prompt.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_skills.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.1}/tests/test_streaming.py +0 -0
|
@@ -18,11 +18,17 @@ import sys
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def load_env_file(env_file):
|
|
21
|
-
"""Load environment variables from .env file.
|
|
21
|
+
"""Load environment variables from .env file.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
set: Keys of the environment variables that were loaded from the file.
|
|
25
|
+
"""
|
|
22
26
|
if not os.path.exists(env_file):
|
|
23
27
|
print(f"❌ Error: .env file not found: {env_file}", file=sys.stderr)
|
|
24
28
|
sys.exit(1)
|
|
25
29
|
|
|
30
|
+
loaded_keys = set()
|
|
31
|
+
|
|
26
32
|
with open(env_file, "r") as f:
|
|
27
33
|
for line in f:
|
|
28
34
|
line = line.strip()
|
|
@@ -43,6 +49,9 @@ def load_env_file(env_file):
|
|
|
43
49
|
value = value[1:-1]
|
|
44
50
|
|
|
45
51
|
os.environ[key] = value
|
|
52
|
+
loaded_keys.add(key)
|
|
53
|
+
|
|
54
|
+
return loaded_keys
|
|
46
55
|
|
|
47
56
|
|
|
48
57
|
def detect_runtime():
|
|
@@ -354,8 +363,17 @@ echo ""
|
|
|
354
363
|
return script
|
|
355
364
|
|
|
356
365
|
|
|
357
|
-
def build_container_args(sandbox_args, patchpal_args):
|
|
358
|
-
"""Build container runtime arguments.
|
|
366
|
+
def build_container_args(sandbox_args, patchpal_args, env_file_keys=None):
|
|
367
|
+
"""Build container runtime arguments.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
sandbox_args: Parsed sandbox CLI arguments
|
|
371
|
+
patchpal_args: Arguments to pass through to patchpal
|
|
372
|
+
env_file_keys: Set of env var names loaded from --env-file (if any).
|
|
373
|
+
These are forwarded into the container even though the
|
|
374
|
+
rest of the host environment is not (isolation mode).
|
|
375
|
+
"""
|
|
376
|
+
env_file_keys = env_file_keys or set()
|
|
359
377
|
runtime = detect_runtime()
|
|
360
378
|
if not runtime:
|
|
361
379
|
print("❌ Error: Neither Docker nor Podman found", file=sys.stderr)
|
|
@@ -456,8 +474,12 @@ def build_container_args(sandbox_args, patchpal_args):
|
|
|
456
474
|
if sandbox_args.cpus:
|
|
457
475
|
container_args.extend(["--cpus", str(sandbox_args.cpus)])
|
|
458
476
|
|
|
459
|
-
# Pass through environment variables
|
|
460
|
-
#
|
|
477
|
+
# Pass through environment variables.
|
|
478
|
+
# WITHOUT --env-file: pass through matching vars from the full host environment.
|
|
479
|
+
# WITH --env-file: isolate from the host, but still forward the vars that were
|
|
480
|
+
# explicitly loaded from the file itself (previously these were
|
|
481
|
+
# loaded into os.environ but never forwarded into the container,
|
|
482
|
+
# causing credentials in --env-file to be silently dropped).
|
|
461
483
|
if not sandbox_args.env_file:
|
|
462
484
|
# Pass through PATCHPAL_* environment variables
|
|
463
485
|
for key, value in os.environ.items():
|
|
@@ -496,6 +518,12 @@ def build_container_args(sandbox_args, patchpal_args):
|
|
|
496
518
|
for key, value in os.environ.items():
|
|
497
519
|
if key.startswith("OLLAMA_"):
|
|
498
520
|
container_args.extend(["-e", f"{key}={value}"])
|
|
521
|
+
else:
|
|
522
|
+
# Isolated mode: only forward the specific vars loaded from --env-file
|
|
523
|
+
for key in env_file_keys:
|
|
524
|
+
value = os.environ.get(key)
|
|
525
|
+
if value is not None:
|
|
526
|
+
container_args.extend(["-e", f"{key}={value}"])
|
|
499
527
|
|
|
500
528
|
# Track mounted paths to avoid duplicates
|
|
501
529
|
mounted_paths = set()
|
|
@@ -1002,9 +1030,10 @@ def main():
|
|
|
1002
1030
|
sandbox_args.network = "host"
|
|
1003
1031
|
|
|
1004
1032
|
# Load .env file if specified
|
|
1033
|
+
env_file_keys = set()
|
|
1005
1034
|
if sandbox_args.env_file:
|
|
1006
1035
|
print(f"Loading environment variables from: {sandbox_args.env_file}")
|
|
1007
|
-
load_env_file(sandbox_args.env_file)
|
|
1036
|
+
env_file_keys = load_env_file(sandbox_args.env_file)
|
|
1008
1037
|
|
|
1009
1038
|
# If LITELLM_KWARGS contains AWS params, normalize env var names to uppercase
|
|
1010
1039
|
# This must happen before endpoint detection for AWS Bedrock auto-detection to work
|
|
@@ -1023,6 +1052,7 @@ def main():
|
|
|
1023
1052
|
upper_key = key.upper()
|
|
1024
1053
|
if upper_key not in os.environ:
|
|
1025
1054
|
os.environ[upper_key] = value
|
|
1055
|
+
env_file_keys.add(upper_key)
|
|
1026
1056
|
except (json.JSONDecodeError, ValueError):
|
|
1027
1057
|
# Not JSON, check if individual env vars exist (comma-separated format)
|
|
1028
1058
|
for key, value in list(os.environ.items()):
|
|
@@ -1030,6 +1060,7 @@ def main():
|
|
|
1030
1060
|
upper_key = key.upper()
|
|
1031
1061
|
if upper_key not in os.environ:
|
|
1032
1062
|
os.environ[upper_key] = value
|
|
1063
|
+
env_file_keys.add(upper_key)
|
|
1033
1064
|
|
|
1034
1065
|
# Auto-detect LLM endpoints if network restrictions are enabled
|
|
1035
1066
|
detected_endpoints = []
|
|
@@ -1060,7 +1091,7 @@ def main():
|
|
|
1060
1091
|
)
|
|
1061
1092
|
|
|
1062
1093
|
# Build container command
|
|
1063
|
-
container_args, runtime = build_container_args(sandbox_args, patchpal_argv)
|
|
1094
|
+
container_args, runtime = build_container_args(sandbox_args, patchpal_argv, env_file_keys)
|
|
1064
1095
|
|
|
1065
1096
|
# Show what we're doing
|
|
1066
1097
|
print(f"Using container runtime: {runtime}")
|
|
@@ -169,7 +169,11 @@ def _extract_shell_command_info(cmd: str) -> tuple[Optional[str], Optional[str]]
|
|
|
169
169
|
|
|
170
170
|
# Shell operators that indicate compound commands
|
|
171
171
|
# Split by && and || first (they group tighter than ;)
|
|
172
|
-
|
|
172
|
+
# IMPORTANT: newline is also a command separator - without splitting on it, a
|
|
173
|
+
# multi-line command like "cd /tmp\npython evil.py" would be tokenized as one
|
|
174
|
+
# blob and misidentified as just "cd" (which is auto-granted as harmless),
|
|
175
|
+
# completely bypassing the permission check for the real command on the second line.
|
|
176
|
+
compound_operators = ["&&", "||", ";", "\n"]
|
|
173
177
|
|
|
174
178
|
# Split by compound operators to find all sub-commands
|
|
175
179
|
commands = [cmd]
|
|
@@ -289,9 +289,21 @@ class URLContextTracker:
|
|
|
289
289
|
# Note: Apostrophes (') are allowed in URLs (e.g., Wikipedia article titles)
|
|
290
290
|
import re
|
|
291
291
|
|
|
292
|
-
url_pattern = r"https?://[^\s<>\"
|
|
292
|
+
url_pattern = r"https?://[^\s<>\"]*"
|
|
293
293
|
urls = re.findall(url_pattern, text)
|
|
294
|
-
|
|
294
|
+
|
|
295
|
+
# Strip trailing punctuation that commonly follows URLs in natural language
|
|
296
|
+
# but is not part of the URL itself
|
|
297
|
+
cleaned_urls = []
|
|
298
|
+
for url in urls:
|
|
299
|
+
# Strip trailing punctuation: , ; . ! ? )
|
|
300
|
+
# but preserve these characters if they appear mid-URL
|
|
301
|
+
while url and url[-1] in ".,;!?)":
|
|
302
|
+
url = url[:-1]
|
|
303
|
+
if url: # Only add non-empty URLs
|
|
304
|
+
cleaned_urls.append(url)
|
|
305
|
+
|
|
306
|
+
self.seen_urls.update(cleaned_urls)
|
|
295
307
|
|
|
296
308
|
def is_url_in_context(self, url: str) -> bool:
|
|
297
309
|
"""Check if URL has appeared in conversation context.
|
|
@@ -48,6 +48,34 @@ def test_shell_command_pattern_with_cd():
|
|
|
48
48
|
assert wd == "/tmp"
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_shell_command_pattern_newline_separated():
|
|
52
|
+
"""Regression test: newline-separated commands must not bypass permission checks.
|
|
53
|
+
|
|
54
|
+
Bug: _extract_shell_command_info() only split on &&, ||, and ; - not newlines.
|
|
55
|
+
A command like "cd /tmp\npython evil.py" (cd and the real command on separate
|
|
56
|
+
lines, with no shell operator joining them) was tokenized as a single blob and
|
|
57
|
+
misidentified as just "cd", which is on the harmless auto-grant list. This let
|
|
58
|
+
an arbitrary second-line command execute without ever triggering a permission
|
|
59
|
+
prompt. See conversation history for full root-cause analysis.
|
|
60
|
+
"""
|
|
61
|
+
from patchpal.tools.shell_tools import _extract_shell_command_info
|
|
62
|
+
|
|
63
|
+
# Basic two-line case: cd on line 1, real command on line 2
|
|
64
|
+
cmd, wd = _extract_shell_command_info("cd /tmp\npython evil.py")
|
|
65
|
+
assert cmd == "python" # Must NOT be "cd"
|
|
66
|
+
assert wd == "/tmp"
|
|
67
|
+
|
|
68
|
+
# Multi-line with || fallback on line 1 (the exact bypass pattern reported)
|
|
69
|
+
cmd, wd = _extract_shell_command_info(
|
|
70
|
+
'cd /repo/a 2>/dev/null || cd /repo/b\npython3 -c "print(1)"'
|
|
71
|
+
)
|
|
72
|
+
assert cmd == "python3" # Must NOT be "cd"
|
|
73
|
+
|
|
74
|
+
# Newline-separated dangerous command must also be detected (not swallowed as "cd")
|
|
75
|
+
cmd, wd = _extract_shell_command_info("cd /tmp\nrm -rf /")
|
|
76
|
+
assert cmd == "rm" # Must NOT be "cd" - "rm" must surface so DANGEROUS_PATTERNS can catch it
|
|
77
|
+
|
|
78
|
+
|
|
51
79
|
def test_shell_command_pattern_with_pipes():
|
|
52
80
|
"""Test shell command pattern extraction with pipes."""
|
|
53
81
|
from patchpal.tools.shell_tools import _extract_shell_command_info
|
|
@@ -679,6 +679,44 @@ def test_web_fetch_invalid_url():
|
|
|
679
679
|
web_fetch("not-a-url")
|
|
680
680
|
|
|
681
681
|
|
|
682
|
+
def test_url_extraction_with_punctuation():
|
|
683
|
+
"""Test that URLs followed by punctuation are extracted correctly."""
|
|
684
|
+
from patchpal.tools.web_tools import URLContextTracker
|
|
685
|
+
|
|
686
|
+
tracker = URLContextTracker()
|
|
687
|
+
|
|
688
|
+
# Test URL followed by comma (the bug that was fixed)
|
|
689
|
+
tracker.clear()
|
|
690
|
+
tracker.add_urls_from_text("Based on https://en.wikipedia.org/robots.txt, is this okay?")
|
|
691
|
+
assert tracker.is_url_in_context("https://en.wikipedia.org/robots.txt")
|
|
692
|
+
|
|
693
|
+
# Test URL followed by period
|
|
694
|
+
tracker.clear()
|
|
695
|
+
tracker.add_urls_from_text("Check out https://example.com. It's great!")
|
|
696
|
+
assert tracker.is_url_in_context("https://example.com")
|
|
697
|
+
|
|
698
|
+
# Test URL followed by semicolon
|
|
699
|
+
tracker.clear()
|
|
700
|
+
tracker.add_urls_from_text("Visit https://docs.python.org; it has good info.")
|
|
701
|
+
assert tracker.is_url_in_context("https://docs.python.org")
|
|
702
|
+
|
|
703
|
+
# Test URL in parentheses
|
|
704
|
+
tracker.clear()
|
|
705
|
+
tracker.add_urls_from_text("Link (https://www.google.com) is here.")
|
|
706
|
+
assert tracker.is_url_in_context("https://www.google.com")
|
|
707
|
+
|
|
708
|
+
# Test multiple URLs with different punctuation
|
|
709
|
+
tracker.clear()
|
|
710
|
+
tracker.add_urls_from_text("Multiple: https://site1.com, https://site2.com.")
|
|
711
|
+
assert tracker.is_url_in_context("https://site1.com")
|
|
712
|
+
assert tracker.is_url_in_context("https://site2.com")
|
|
713
|
+
|
|
714
|
+
# Test URL with apostrophe (should be preserved - part of URL)
|
|
715
|
+
tracker.clear()
|
|
716
|
+
tracker.add_urls_from_text("URL: https://en.wikipedia.org/wiki/O'Brien works")
|
|
717
|
+
assert tracker.is_url_in_context("https://en.wikipedia.org/wiki/O'Brien")
|
|
718
|
+
|
|
719
|
+
|
|
682
720
|
def test_web_fetch_content_too_large(monkeypatch):
|
|
683
721
|
"""Test that large content is rejected."""
|
|
684
722
|
from unittest.mock import Mock
|
|
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
|
|
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
|
|
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
|
|
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
|