patchpal 0.24.0__tar.gz → 0.24.2__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.
Files changed (70) hide show
  1. {patchpal-0.24.0/patchpal.egg-info → patchpal-0.24.2}/PKG-INFO +1 -1
  2. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/__init__.py +1 -1
  3. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/bedrock_profile_utils.py +26 -1
  4. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/sandbox.py +97 -15
  5. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/shell_tools.py +5 -1
  6. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/web_tools.py +14 -2
  7. {patchpal-0.24.0 → patchpal-0.24.2/patchpal.egg-info}/PKG-INFO +1 -1
  8. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_permissions.py +28 -0
  9. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_tools.py +38 -0
  10. {patchpal-0.24.0 → patchpal-0.24.2}/LICENSE +0 -0
  11. {patchpal-0.24.0 → patchpal-0.24.2}/MANIFEST.in +0 -0
  12. {patchpal-0.24.0 → patchpal-0.24.2}/README.md +0 -0
  13. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/__init__.py +0 -0
  14. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/function_calling.py +0 -0
  15. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/react.py +0 -0
  16. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/__init__.py +0 -0
  17. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/autopilot.py +0 -0
  18. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/interactive.py +0 -0
  19. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/mcp.py +0 -0
  20. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/streaming.py +0 -0
  21. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/config.py +0 -0
  22. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/context.py +0 -0
  23. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/permissions.py +0 -0
  24. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/prompts/react_prompt.md +0 -0
  25. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/prompts/system_prompt.md +0 -0
  26. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/skills.py +0 -0
  27. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/__init__.py +0 -0
  28. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/audit.py +0 -0
  29. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/browser_tools.py +0 -0
  30. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/code_analysis.py +0 -0
  31. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/common.py +0 -0
  32. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/definitions.py +0 -0
  33. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/file_reading.py +0 -0
  34. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/file_writing.py +0 -0
  35. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/find_tool.py +0 -0
  36. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/grep_tool.py +0 -0
  37. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/image_handler.py +0 -0
  38. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/mcp.py +0 -0
  39. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/repo_map.py +0 -0
  40. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/todo_tools.py +0 -0
  41. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/tool_schema.py +0 -0
  42. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/user_interaction.py +0 -0
  43. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/SOURCES.txt +0 -0
  44. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/dependency_links.txt +0 -0
  45. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/entry_points.txt +0 -0
  46. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/requires.txt +0 -0
  47. {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/top_level.txt +0 -0
  48. {patchpal-0.24.0 → patchpal-0.24.2}/pyproject.toml +0 -0
  49. {patchpal-0.24.0 → patchpal-0.24.2}/setup.cfg +0 -0
  50. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_agent.py +0 -0
  51. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_cli.py +0 -0
  52. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_config_dynamic.py +0 -0
  53. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_context.py +0 -0
  54. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_custom_tools.py +0 -0
  55. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_enabled_tools.py +0 -0
  56. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_find_tool.py +0 -0
  57. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_guardrails.py +0 -0
  58. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_image_blocking.py +0 -0
  59. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_maximum_security.py +0 -0
  60. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_mcp_config.py +0 -0
  61. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_memory.py +0 -0
  62. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_memory_locations.py +0 -0
  63. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_operational_safety.py +0 -0
  64. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_optional_tools.py +0 -0
  65. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_react.py +0 -0
  66. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_reasoning_content.py +0 -0
  67. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_repo_map.py +0 -0
  68. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_simplified_prompt.py +0 -0
  69. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_skills.py +0 -0
  70. {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.24.0
3
+ Version: 0.24.2
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  """PatchPal - An open-source Claude Code clone implemented purely in Python."""
2
2
 
3
- __version__ = "0.24.0"
3
+ __version__ = "0.24.2"
4
4
 
5
5
  from patchpal.agent import create_agent, create_react_agent
6
6
  from patchpal.cli.autopilot import autopilot_loop
@@ -29,8 +29,33 @@ def _extract_model_from_arn(arn: str) -> str | None:
29
29
  else:
30
30
  region = os.getenv("AWS_REGION_NAME") or os.getenv("AWS_REGION") or "us-east-1"
31
31
 
32
+ # Check for custom Bedrock endpoint (VPC endpoint, custom domain, etc.)
33
+ # The control plane and runtime may use the same VPC endpoint base
34
+ custom_endpoint = (
35
+ os.getenv("AWS_BEDROCK_ENDPOINT")
36
+ or os.getenv("AWS_ENDPOINT_URL_BEDROCK")
37
+ or os.getenv("AWS_ENDPOINT_URL")
38
+ )
39
+
40
+ # CRITICAL FIX: Derive control plane endpoint from runtime VPC endpoint
41
+ # Scenario: User has VPC endpoint for runtime but not explicit control plane endpoint
42
+ # Problem: boto3.client("bedrock") defaults to public endpoint, not VPC endpoint
43
+ # Solution: Automatically derive control plane VPC endpoint from runtime endpoint
44
+ # VPC endpoint pattern:
45
+ # Runtime: vpce-{id}.bedrock-runtime.{region}.vpce.amazonaws.com
46
+ # Control plane: vpce-{id}.bedrock.{region}.vpce.amazonaws.com
47
+ # This matches the automatic derivation in sandbox.py endpoint detection
48
+ if custom_endpoint and "bedrock-runtime" in custom_endpoint:
49
+ # Try to construct control plane VPC endpoint by removing -runtime
50
+ control_endpoint = custom_endpoint.replace("bedrock-runtime", "bedrock")
51
+ else:
52
+ control_endpoint = custom_endpoint
53
+
32
54
  # Create bedrock client
33
- bedrock = boto3.client("bedrock", region_name=region)
55
+ if control_endpoint:
56
+ bedrock = boto3.client("bedrock", region_name=region, endpoint_url=control_endpoint)
57
+ else:
58
+ bedrock = boto3.client("bedrock", region_name=region)
34
59
 
35
60
  # Get inference profile details
36
61
  response = bedrock.get_inference_profile(inferenceProfileIdentifier=arn)
@@ -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():
@@ -135,6 +144,7 @@ def detect_llm_endpoints_from_env():
135
144
  "ANTHROPIC_API_BASE",
136
145
  "AWS_BEDROCK_ENDPOINT",
137
146
  "AWS_ENDPOINT_URL_BEDROCK_RUNTIME",
147
+ "AWS_ENDPOINT_URL_BEDROCK", # Control plane endpoint
138
148
  "AWS_ENDPOINT_URL",
139
149
  "AWS_BEDROCK_RUNTIME_ENDPOINT",
140
150
  "AZURE_OPENAI_ENDPOINT",
@@ -153,6 +163,18 @@ def detect_llm_endpoints_from_env():
153
163
  # Ensure it's a valid URL
154
164
  if value.startswith("http://") or value.startswith("https://"):
155
165
  detected_urls.append(value)
166
+
167
+ # CRITICAL FIX: For Bedrock VPC endpoints, derive control plane endpoint
168
+ # Scenario: User has VPC endpoint for runtime API (e.g., AWS GovCloud private network)
169
+ # Problem: Model capability detection needs control plane API (get_inference_profile)
170
+ # but boto3 doesn't automatically derive control plane from runtime endpoint
171
+ # VPC endpoint pattern:
172
+ # Runtime: vpce-{id}.bedrock-runtime.{region}.vpce.amazonaws.com
173
+ # Control plane: vpce-{id}.bedrock.{region}.vpce.amazonaws.com
174
+ # Without this, capability detection hangs trying to reach public control plane endpoint
175
+ if "bedrock-runtime" in value and ".vpce.amazonaws.com" in value:
176
+ control_plane_url = value.replace("bedrock-runtime", "bedrock")
177
+ detected_urls.append(control_plane_url)
156
178
  else:
157
179
  # Assume https if no protocol specified
158
180
  detected_urls.append(f"https://{value}")
@@ -168,18 +190,37 @@ def detect_llm_endpoints_from_env():
168
190
  if aws_region and (
169
191
  os.environ.get("AWS_ACCESS_KEY_ID") or os.environ.get("AWS_SECRET_ACCESS_KEY")
170
192
  ):
171
- # Add Bedrock endpoint for the region
193
+ # Add Bedrock endpoints for the region
172
194
  # Support both GovCloud and standard regions
173
195
  # Also support AWS China regions
196
+
197
+ # IMPORTANT: boto3 uses standard endpoint names by default, even in GovCloud
198
+ # Only use -fips suffix if AWS_USE_FIPS_ENDPOINT=true is explicitly set
199
+ # Scenario 1: Standard GovCloud → bedrock.us-gov-east-1.amazonaws.com (boto3 default)
200
+ # Scenario 2: FIPS required → bedrock-fips.us-gov-east-1.amazonaws.com (explicit opt-in)
201
+ use_fips = os.environ.get("AWS_USE_FIPS_ENDPOINT", "").lower() == "true"
202
+
174
203
  if "gov" in aws_region:
175
- # GovCloud regions use FIPS endpoints
176
- detected_urls.append(f"https://bedrock-runtime-fips.{aws_region}.amazonaws.com")
204
+ # GovCloud regions
205
+ # Add both runtime (for LLM inference) and control plane (for model capability detection)
206
+ if use_fips:
207
+ # FIPS endpoints (only when explicitly requested via AWS_USE_FIPS_ENDPOINT=true)
208
+ detected_urls.append(f"https://bedrock-runtime-fips.{aws_region}.amazonaws.com")
209
+ detected_urls.append(f"https://bedrock-fips.{aws_region}.amazonaws.com")
210
+ else:
211
+ # Standard GovCloud endpoints (boto3 default behavior)
212
+ # This is what boto3 uses unless FIPS is explicitly configured
213
+ detected_urls.append(f"https://bedrock-runtime.{aws_region}.amazonaws.com")
214
+ detected_urls.append(f"https://bedrock.{aws_region}.amazonaws.com")
177
215
  elif "cn-" in aws_region:
178
- # AWS China regions use .amazonaws.com.cn
216
+ # AWS China regions use .amazonaws.com.cn domain
179
217
  detected_urls.append(f"https://bedrock-runtime.{aws_region}.amazonaws.com.cn")
218
+ detected_urls.append(f"https://bedrock.{aws_region}.amazonaws.com.cn")
180
219
  else:
181
- # Standard commercial regions
220
+ # Standard commercial regions (us-east-1, eu-west-1, etc.)
221
+ # Add both runtime (for LLM inference) and control plane (for model capability detection)
182
222
  detected_urls.append(f"https://bedrock-runtime.{aws_region}.amazonaws.com")
223
+ detected_urls.append(f"https://bedrock.{aws_region}.amazonaws.com")
183
224
 
184
225
  # Azure OpenAI: Extract from resource name if AZURE_OPENAI_KEY is set
185
226
  azure_key = os.environ.get("AZURE_OPENAI_KEY") or os.environ.get("AZURE_API_KEY")
@@ -267,9 +308,28 @@ fi
267
308
  # Configure iptables
268
309
  echo "Configuring firewall rules..."
269
310
 
270
- # Allow DNS (required for hostname resolution)
271
- iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
272
- iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
311
+ # Extract DNS resolver IP(s) from /etc/resolv.conf
312
+ # This locks DNS queries to only the container's configured resolver,
313
+ # preventing agents from directly querying attacker-controlled DNS servers
314
+ RESOLVER_IPS=$(grep '^nameserver' /etc/resolv.conf | awk '{print $2}')
315
+
316
+ if [ -z "$RESOLVER_IPS" ]; then
317
+ echo "❌ ERROR: Could not detect DNS resolver from /etc/resolv.conf"
318
+ echo "Cannot configure secure DNS filtering without a resolver"
319
+ exit 1
320
+ fi
321
+
322
+ # Allow DNS ONLY to detected resolver IPs (SECURITY FIX)
323
+ # This prevents the OpenAI-style DNS tunneling attack where agents
324
+ # send DNS packets directly to attacker-controlled DNS servers
325
+ echo "Locking DNS queries to container's resolver(s):"
326
+ while IFS= read -r RESOLVER_IP; do
327
+ if [ -n "$RESOLVER_IP" ]; then
328
+ echo " $RESOLVER_IP (DNS)"
329
+ iptables -A OUTPUT -d $RESOLVER_IP -p udp --dport 53 -j ACCEPT
330
+ iptables -A OUTPUT -d $RESOLVER_IP -p tcp --dport 53 -j ACCEPT
331
+ fi
332
+ done <<< "$RESOLVER_IPS"
273
333
 
274
334
  # Allow localhost
275
335
  iptables -A OUTPUT -d 127.0.0.1/8 -j ACCEPT
@@ -354,8 +414,17 @@ echo ""
354
414
  return script
355
415
 
356
416
 
357
- def build_container_args(sandbox_args, patchpal_args):
358
- """Build container runtime arguments."""
417
+ def build_container_args(sandbox_args, patchpal_args, env_file_keys=None):
418
+ """Build container runtime arguments.
419
+
420
+ Args:
421
+ sandbox_args: Parsed sandbox CLI arguments
422
+ patchpal_args: Arguments to pass through to patchpal
423
+ env_file_keys: Set of env var names loaded from --env-file (if any).
424
+ These are forwarded into the container even though the
425
+ rest of the host environment is not (isolation mode).
426
+ """
427
+ env_file_keys = env_file_keys or set()
359
428
  runtime = detect_runtime()
360
429
  if not runtime:
361
430
  print("❌ Error: Neither Docker nor Podman found", file=sys.stderr)
@@ -456,8 +525,12 @@ def build_container_args(sandbox_args, patchpal_args):
456
525
  if sandbox_args.cpus:
457
526
  container_args.extend(["--cpus", str(sandbox_args.cpus)])
458
527
 
459
- # Pass through environment variables from host ONLY if --env-file is NOT provided
460
- # This provides better isolation - when using --env-file, only those specific vars are used
528
+ # Pass through environment variables.
529
+ # WITHOUT --env-file: pass through matching vars from the full host environment.
530
+ # WITH --env-file: isolate from the host, but still forward the vars that were
531
+ # explicitly loaded from the file itself (previously these were
532
+ # loaded into os.environ but never forwarded into the container,
533
+ # causing credentials in --env-file to be silently dropped).
461
534
  if not sandbox_args.env_file:
462
535
  # Pass through PATCHPAL_* environment variables
463
536
  for key, value in os.environ.items():
@@ -496,6 +569,12 @@ def build_container_args(sandbox_args, patchpal_args):
496
569
  for key, value in os.environ.items():
497
570
  if key.startswith("OLLAMA_"):
498
571
  container_args.extend(["-e", f"{key}={value}"])
572
+ else:
573
+ # Isolated mode: only forward the specific vars loaded from --env-file
574
+ for key in env_file_keys:
575
+ value = os.environ.get(key)
576
+ if value is not None:
577
+ container_args.extend(["-e", f"{key}={value}"])
499
578
 
500
579
  # Track mounted paths to avoid duplicates
501
580
  mounted_paths = set()
@@ -1002,9 +1081,10 @@ def main():
1002
1081
  sandbox_args.network = "host"
1003
1082
 
1004
1083
  # Load .env file if specified
1084
+ env_file_keys = set()
1005
1085
  if sandbox_args.env_file:
1006
1086
  print(f"Loading environment variables from: {sandbox_args.env_file}")
1007
- load_env_file(sandbox_args.env_file)
1087
+ env_file_keys = load_env_file(sandbox_args.env_file)
1008
1088
 
1009
1089
  # If LITELLM_KWARGS contains AWS params, normalize env var names to uppercase
1010
1090
  # This must happen before endpoint detection for AWS Bedrock auto-detection to work
@@ -1023,6 +1103,7 @@ def main():
1023
1103
  upper_key = key.upper()
1024
1104
  if upper_key not in os.environ:
1025
1105
  os.environ[upper_key] = value
1106
+ env_file_keys.add(upper_key)
1026
1107
  except (json.JSONDecodeError, ValueError):
1027
1108
  # Not JSON, check if individual env vars exist (comma-separated format)
1028
1109
  for key, value in list(os.environ.items()):
@@ -1030,6 +1111,7 @@ def main():
1030
1111
  upper_key = key.upper()
1031
1112
  if upper_key not in os.environ:
1032
1113
  os.environ[upper_key] = value
1114
+ env_file_keys.add(upper_key)
1033
1115
 
1034
1116
  # Auto-detect LLM endpoints if network restrictions are enabled
1035
1117
  detected_endpoints = []
@@ -1060,7 +1142,7 @@ def main():
1060
1142
  )
1061
1143
 
1062
1144
  # Build container command
1063
- container_args, runtime = build_container_args(sandbox_args, patchpal_argv)
1145
+ container_args, runtime = build_container_args(sandbox_args, patchpal_argv, env_file_keys)
1064
1146
 
1065
1147
  # Show what we're doing
1066
1148
  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
- compound_operators = ["&&", "||", ";"]
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
- self.seen_urls.update(urls)
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchpal
3
- Version: 0.24.0
3
+ Version: 0.24.2
4
4
  Summary: An agentic coding and automation assistant, supporting both local and cloud LLMs
5
5
  Author: PatchPal Contributors
6
6
  License-Expression: Apache-2.0
@@ -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