zerochat 9.2.0__tar.gz → 9.3.0__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.

Potentially problematic release.


This version of zerochat might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.2.0
3
+ Version: 9.3.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "zerochat"
7
- version = "9.2.0"
7
+ version = "9.3.0"
8
8
  description = "Private local web client for AI models, tools and MCP services"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 9.2.0
3
+ Version: 9.3.0
4
4
  Summary: Private local web client for AI models, tools and MCP services
5
5
  Author: Alberto Alday
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ import base64
17
17
  import datetime
18
18
  import fnmatch
19
19
  import functools
20
+ import gzip
20
21
  import hashlib
21
22
  import hmac
22
23
  import importlib.metadata
@@ -36,14 +37,16 @@ import threading
36
37
  import time
37
38
  import traceback
38
39
  import urllib.error
40
+ import urllib.parse
39
41
  import urllib.request
40
42
  from urllib.parse import urlencode
41
43
  import venv
42
44
  import webbrowser
45
+ import zlib
43
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
44
47
  from pathlib import Path
45
48
 
46
- SOURCE_BACKEND_VERSION = "9.2.0"
49
+ SOURCE_BACKEND_VERSION = "9.3.0"
47
50
 
48
51
  def _read_source_version(filename: str) -> str | None:
49
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1350,9 +1353,11 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1350
1353
  if not target.is_dir():
1351
1354
  return _tool_error(f"Path '{path}' is not a directory.")
1352
1355
 
1356
+ warning = None
1353
1357
  try:
1354
1358
  regex = re.compile(query, re.MULTILINE)
1355
- except re.error:
1359
+ except re.error as err:
1360
+ warning = f"Query '{query}' is not a valid regular expression ({err}); treated as literal text."
1356
1361
  regex = re.compile(re.escape(query), re.MULTILINE)
1357
1362
 
1358
1363
  try:
@@ -1412,7 +1417,7 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1412
1417
  if truncated:
1413
1418
  break
1414
1419
 
1415
- return json.dumps({
1420
+ payload = {
1416
1421
  "success": True,
1417
1422
  "path": str(target),
1418
1423
  "query": query,
@@ -1421,7 +1426,11 @@ def search_files(query: str, path: str = ".", file_pattern: str = None, max_resu
1421
1426
  "total_matches": len(matches),
1422
1427
  "truncated": truncated,
1423
1428
  "matches": matches
1424
- }, ensure_ascii=False, indent=2)
1429
+ }
1430
+ if warning:
1431
+ payload["warning"] = warning
1432
+
1433
+ return json.dumps(payload, ensure_ascii=False, indent=2)
1425
1434
  except Exception as e:
1426
1435
  return _tool_error(str(e))
1427
1436
 
@@ -2700,6 +2709,178 @@ DEV_CONTENT_TYPES = {
2700
2709
  ".map": "application/json",
2701
2710
  }
2702
2711
 
2712
+ METADATA_HOSTS = frozenset({
2713
+ "169.254.169.254",
2714
+ "metadata.google.internal",
2715
+ "instance-data",
2716
+ "100.100.100.200",
2717
+ })
2718
+ DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
2719
+
2720
+
2721
+ def is_dangerous_host(hostname: str, allow_local: bool = False) -> bool:
2722
+ """Verifica si un host representa un riesgo de SSRF (metadatos cloud, loopback o privados)."""
2723
+ if not hostname:
2724
+ return True
2725
+ clean_host = hostname.strip().lower().strip("[]")
2726
+ if clean_host in METADATA_HOSTS:
2727
+ return True
2728
+
2729
+ try:
2730
+ ip = ipaddress.ip_address(clean_host)
2731
+ if ip.is_link_local:
2732
+ return True
2733
+ if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2734
+ return True
2735
+ return False
2736
+ except ValueError:
2737
+ pass
2738
+
2739
+ if clean_host.isdigit():
2740
+ try:
2741
+ num = int(clean_host)
2742
+ if 0 <= num <= 4294967295:
2743
+ ip = ipaddress.IPv4Address(num)
2744
+ if ip.is_link_local:
2745
+ return True
2746
+ if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2747
+ return True
2748
+ return False
2749
+ except ValueError:
2750
+ pass
2751
+
2752
+ if clean_host.startswith("0x"):
2753
+ try:
2754
+ num = int(clean_host, 16)
2755
+ if 0 <= num <= 4294967295:
2756
+ ip = ipaddress.IPv4Address(num)
2757
+ if ip.is_link_local:
2758
+ return True
2759
+ if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2760
+ return True
2761
+ return False
2762
+ except ValueError:
2763
+ pass
2764
+
2765
+ if not allow_local:
2766
+ if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
2767
+ return True
2768
+ if clean_host.endswith(".local") or clean_host.endswith(".internal"):
2769
+ return True
2770
+
2771
+ return False
2772
+
2773
+
2774
+ def fetch_web_resource(params: dict) -> dict:
2775
+ """Descarga de forma segura un recurso web vía HTTP/HTTPS para el cliente web."""
2776
+ url = params.get("url")
2777
+ if not isinstance(url, str) or not url.strip():
2778
+ raise ValueError("URL no válida o ausente.")
2779
+ url = url.strip()
2780
+ parsed = urllib.parse.urlparse(url)
2781
+ if parsed.scheme not in ("http", "https"):
2782
+ raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2783
+ hostname = (parsed.hostname or "").lower().strip("[]")
2784
+ if not hostname:
2785
+ raise ValueError("URL sin nombre de host válido.")
2786
+
2787
+ allow_local = bool(params.get("allow_local", False))
2788
+ if is_dangerous_host(hostname, allow_local):
2789
+ raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2790
+
2791
+ timeout_s = float(params.get("timeout_s", 10.0))
2792
+ timeout_s = max(0.5, min(timeout_s, 30.0))
2793
+ max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
2794
+ max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
2795
+ as_binary = bool(params.get("binary", False))
2796
+
2797
+ req_headers = {
2798
+ "User-Agent": DEFAULT_USER_AGENT,
2799
+ "Accept": "text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.8",
2800
+ "Accept-Encoding": "gzip, deflate",
2801
+ }
2802
+ custom_headers = params.get("headers")
2803
+ if isinstance(custom_headers, dict):
2804
+ for k, v in custom_headers.items():
2805
+ if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
2806
+ req_headers[k] = v
2807
+
2808
+ req = urllib.request.Request(url, headers=req_headers)
2809
+ try:
2810
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
2811
+ status = resp.status
2812
+ content_type = resp.headers.get("Content-Type", "")
2813
+ content_encoding = resp.headers.get("Content-Encoding", "").lower()
2814
+ raw_data = resp.read(max_bytes + 1)
2815
+ truncated = len(raw_data) > max_bytes
2816
+ if truncated:
2817
+ raw_data = raw_data[:max_bytes]
2818
+
2819
+ if "gzip" in content_encoding:
2820
+ try:
2821
+ raw_data = gzip.decompress(raw_data)
2822
+ except (OSError, gzip.BadGzipFile):
2823
+ pass
2824
+ elif "deflate" in content_encoding:
2825
+ try:
2826
+ raw_data = zlib.decompress(raw_data)
2827
+ except (OSError, zlib.error):
2828
+ pass
2829
+
2830
+ resp_headers = {k: v for k, v in resp.getheaders()}
2831
+
2832
+ if as_binary:
2833
+ return {
2834
+ "ok": True,
2835
+ "status": status,
2836
+ "content_type": content_type,
2837
+ "headers": resp_headers,
2838
+ "base64": base64.b64encode(raw_data).decode("ascii"),
2839
+ "byte_size": len(raw_data),
2840
+ "truncated": truncated
2841
+ }
2842
+
2843
+ charset = resp.headers.get_content_charset() or "utf-8"
2844
+ try:
2845
+ text_content = raw_data.decode(charset, errors="replace")
2846
+ return {
2847
+ "ok": True,
2848
+ "status": status,
2849
+ "content_type": content_type,
2850
+ "headers": resp_headers,
2851
+ "text": text_content,
2852
+ "byte_size": len(raw_data),
2853
+ "truncated": truncated
2854
+ }
2855
+ except (UnicodeDecodeError, LookupError):
2856
+ return {
2857
+ "ok": True,
2858
+ "status": status,
2859
+ "content_type": content_type,
2860
+ "headers": resp_headers,
2861
+ "base64": base64.b64encode(raw_data).decode("ascii"),
2862
+ "byte_size": len(raw_data),
2863
+ "truncated": truncated
2864
+ }
2865
+ except urllib.error.HTTPError as he:
2866
+ err_body = ""
2867
+ try:
2868
+ err_body = he.read(2048).decode("utf-8", errors="replace")
2869
+ except (OSError, UnicodeDecodeError):
2870
+ pass
2871
+ return {
2872
+ "ok": False,
2873
+ "status": he.code,
2874
+ "error": f"HTTP {he.code}: {he.reason}",
2875
+ "text": err_body
2876
+ }
2877
+ except Exception as exc:
2878
+ return {
2879
+ "ok": False,
2880
+ "status": 0,
2881
+ "error": str(exc)
2882
+ }
2883
+
2703
2884
 
2704
2885
  class ZeroChatServerHandler(BaseHTTPRequestHandler):
2705
2886
  server_version = f"ZeroChatServer/{VERSION}"
@@ -3105,6 +3286,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3105
3286
  return set_project_directory(params.get("path")), None, ""
3106
3287
  except (ValueError, OSError) as exc:
3107
3288
  return None, {"code": -32602, "message": str(exc)}, ""
3289
+ if method == "zerochat/web/fetch":
3290
+ try:
3291
+ return fetch_web_resource(params), None, ""
3292
+ except (ValueError, PermissionError) as exc:
3293
+ return None, {"code": -32602, "message": str(exc)}, ""
3294
+ except Exception as exc:
3295
+ return None, {"code": -32603, "message": f"Error en transporte web: {exc}"}, str(exc)
3108
3296
  if method.startswith("zerochat/jobs/"):
3109
3297
  return self._dispatch_jobs(method, params)
3110
3298
  if method == "zerochat/external/status":
File without changes
File without changes
File without changes