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.
- {patchpal-0.24.0/patchpal.egg-info → patchpal-0.24.2}/PKG-INFO +1 -1
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/__init__.py +1 -1
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/bedrock_profile_utils.py +26 -1
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/sandbox.py +97 -15
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/shell_tools.py +5 -1
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/web_tools.py +14 -2
- {patchpal-0.24.0 → patchpal-0.24.2/patchpal.egg-info}/PKG-INFO +1 -1
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_permissions.py +28 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_tools.py +38 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/LICENSE +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/MANIFEST.in +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/README.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/function_calling.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/agent/react.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/autopilot.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/interactive.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/mcp.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/cli/streaming.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/config.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/context.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/permissions.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/prompts/react_prompt.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/prompts/system_prompt.md +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/skills.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/__init__.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/audit.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/browser_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/code_analysis.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/common.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/definitions.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/file_reading.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/file_writing.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/find_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/grep_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/image_handler.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/mcp.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/repo_map.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/todo_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/tool_schema.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal/tools/user_interaction.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/SOURCES.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/pyproject.toml +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/setup.cfg +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_agent.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_cli.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_config_dynamic.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_context.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_custom_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_enabled_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_find_tool.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_guardrails.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_image_blocking.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_maximum_security.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_mcp_config.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_memory.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_memory_locations.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_operational_safety.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_optional_tools.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_react.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_reasoning_content.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_repo_map.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_simplified_prompt.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_skills.py +0 -0
- {patchpal-0.24.0 → patchpal-0.24.2}/tests/test_streaming.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
176
|
-
|
|
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
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
460
|
-
#
|
|
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
|
-
|
|
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
|