zerochat 9.4.0__tar.gz → 9.5.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.4.0
3
+ Version: 9.5.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.4.0"
7
+ version = "9.5.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.4.0
3
+ Version: 9.5.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,7 +17,6 @@ import base64
17
17
  import datetime
18
18
  import fnmatch
19
19
  import functools
20
- import gzip
21
20
  import hashlib
22
21
  import hmac
23
22
  import importlib.metadata
@@ -30,6 +29,7 @@ import secrets
30
29
  import shutil
31
30
  import shlex
32
31
  import signal
32
+ import socket
33
33
  import subprocess
34
34
  import sys
35
35
  import tempfile
@@ -46,7 +46,7 @@ import zlib
46
46
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
47
47
  from pathlib import Path
48
48
 
49
- SOURCE_BACKEND_VERSION = "9.4.0"
49
+ SOURCE_BACKEND_VERSION = "9.5.0"
50
50
 
51
51
  def _read_source_version(filename: str) -> str | None:
52
52
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -1344,15 +1344,32 @@ def set_project_directory(path) -> dict:
1344
1344
  return {"cwd": str(target), "previous_cwd": previous}
1345
1345
 
1346
1346
 
1347
- def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = False) -> dict:
1347
+ def _configured_gui_diff_tool(git_bin: str, repo_root: Path) -> str:
1348
+ """Herramienta gráfica que usaría `git difftool --gui`, o '' si no hay ninguna configurada.
1349
+
1350
+ Sin diff.guitool ni merge.guitool, git recurre a diff.tool o adivina una, que puede ser de
1351
+ terminal (vimdiff): lanzada sin terminal quedaría invisible y colgada.
1352
+ """
1353
+ for key in ("diff.guitool", "merge.guitool"):
1354
+ res = subprocess.run(
1355
+ [git_bin, "config", "--get", key],
1356
+ cwd=str(repo_root),
1357
+ capture_output=True,
1358
+ text=True,
1359
+ timeout=5,
1360
+ check=False
1361
+ )
1362
+ if res.returncode == 0 and res.stdout.strip():
1363
+ return res.stdout.strip()
1364
+ return ""
1365
+
1366
+
1367
+ def open_file_diff(path: str, commit_fallback: bool = False) -> dict:
1348
1368
  """Abre un visor externo de diffs (git difftool) para comparar el fichero actual frente a Git HEAD, su último commit o fichero vacío."""
1349
1369
  if not isinstance(path, str) or not path.strip():
1350
1370
  raise ValueError("La ruta del archivo está vacía.")
1351
1371
  if len(path) > MAX_PATH_LENGTH:
1352
1372
  raise ValueError("La ruta del archivo excede el tamaño máximo permitido.")
1353
- if tool is not None:
1354
- if not isinstance(tool, str) or not re.match(r"^[a-zA-Z0-9_-]{1,32}$", tool):
1355
- raise ValueError("Nombre de herramienta de diff inválido.")
1356
1373
 
1357
1374
  target = Path(path.strip()).expanduser().resolve()
1358
1375
  if not target.exists():
@@ -1380,6 +1397,11 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1380
1397
  raise ValueError(f"El archivo '{target.name}' no pertenece a ningún repositorio Git.")
1381
1398
 
1382
1399
  repo_root = Path(res.stdout.strip()).resolve()
1400
+ if not _configured_gui_diff_tool(git_bin, repo_root):
1401
+ raise ValueError(
1402
+ "No hay un visor gráfico de diffs configurado en Git. Configura uno, por ejemplo: "
1403
+ "git config --global diff.guitool meld"
1404
+ )
1383
1405
 
1384
1406
  has_head = subprocess.run(
1385
1407
  [git_bin, "rev-parse", "--verify", "HEAD"],
@@ -1414,11 +1436,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1414
1436
  except ValueError:
1415
1437
  in_head = False
1416
1438
 
1417
- cmd = [git_bin, "difftool", "--no-prompt", "-y"]
1418
- if tool:
1419
- cmd.append(f"--tool={tool}")
1420
- else:
1421
- cmd.append("--gui")
1439
+ cmd = [git_bin, "difftool", "--no-prompt", "--gui"]
1422
1440
 
1423
1441
  mode = "working_tree"
1424
1442
  last_commit = ""
@@ -1465,6 +1483,7 @@ def open_file_diff(path: str, tool: str | None = None, commit_fallback: bool = F
1465
1483
 
1466
1484
  popen_kwargs = {
1467
1485
  "cwd": str(repo_root),
1486
+ "stdin": subprocess.DEVNULL,
1468
1487
  "stdout": subprocess.DEVNULL,
1469
1488
  "stderr": subprocess.DEVNULL,
1470
1489
  }
@@ -2860,59 +2879,83 @@ METADATA_HOSTS = frozenset({
2860
2879
  "100.100.100.200",
2861
2880
  })
2862
2881
  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"
2863
-
2864
-
2865
- def is_dangerous_host(hostname: str, allow_local: bool = False) -> bool:
2866
- """Verifica si un host representa un riesgo de SSRF (metadatos cloud, loopback o privados)."""
2867
- if not hostname:
2882
+ WEB_FETCH_MAX_BYTES = 10 * 1024 * 1024
2883
+ WEB_FETCH_MAX_REDIRECTS = 5
2884
+ WEB_FETCH_READ_CHUNK = 64 * 1024
2885
+ TEXT_CONTENT_TYPE_RE = re.compile(
2886
+ r"^(text/|application/(json|xml|xhtml\+xml|javascript|x-javascript|ecmascript)$|[a-z]+/[^;]*\+(json|xml)$)"
2887
+ )
2888
+
2889
+
2890
+ def _is_public_ip(address: str) -> bool:
2891
+ """Una dirección es pública si es global y no multicast; las IPv4 mapeadas en IPv6 se evalúan como IPv4."""
2892
+ ip = ipaddress.ip_address(address.split("%", 1)[0])
2893
+ if ip.version == 6 and ip.ipv4_mapped:
2894
+ ip = ip.ipv4_mapped
2895
+ return ip.is_global and not ip.is_multicast
2896
+
2897
+
2898
+ def is_dangerous_host(hostname: str) -> bool:
2899
+ """Riesgo de SSRF: metadatos cloud, nombres locales o cualquier dirección resuelta no pública."""
2900
+ clean_host = (hostname or "").strip().lower().strip("[]").rstrip(".")
2901
+ if not clean_host or clean_host in METADATA_HOSTS or clean_host == "localhost":
2868
2902
  return True
2869
- clean_host = hostname.strip().lower().strip("[]")
2870
- if clean_host in METADATA_HOSTS:
2903
+ if clean_host.endswith((".localhost", ".local", ".internal")):
2871
2904
  return True
2872
-
2873
2905
  try:
2874
- ip = ipaddress.ip_address(clean_host)
2875
- if ip.is_link_local:
2876
- return True
2877
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2878
- return True
2906
+ infos = socket.getaddrinfo(clean_host, None, proto=socket.IPPROTO_TCP)
2907
+ except (socket.gaierror, UnicodeError):
2908
+ # Sin resolución no hay conexión posible: la petición fallará por sí misma.
2879
2909
  return False
2880
- except ValueError:
2881
- pass
2910
+ return any(not _is_public_ip(info[4][0]) for info in infos)
2882
2911
 
2883
- if clean_host.isdigit():
2884
- try:
2885
- num = int(clean_host)
2886
- if 0 <= num <= 4294967295:
2887
- ip = ipaddress.IPv4Address(num)
2888
- if ip.is_link_local:
2889
- return True
2890
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2891
- return True
2892
- return False
2893
- except ValueError:
2894
- pass
2895
2912
 
2896
- if clean_host.startswith("0x"):
2897
- try:
2898
- num = int(clean_host, 16)
2899
- if 0 <= num <= 4294967295:
2900
- ip = ipaddress.IPv4Address(num)
2901
- if ip.is_link_local:
2902
- return True
2903
- if not allow_local and (ip.is_loopback or ip.is_private or ip.is_reserved or ip.is_unspecified):
2904
- return True
2905
- return False
2906
- except ValueError:
2907
- pass
2913
+ def _validate_web_url(url: str) -> str:
2914
+ """Valida esquema y host de una URL de descarga (también de cada redirección)."""
2915
+ parsed = urllib.parse.urlparse(url)
2916
+ if parsed.scheme not in ("http", "https"):
2917
+ raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2918
+ if not parsed.hostname:
2919
+ raise ValueError("URL sin nombre de host válido.")
2920
+ if is_dangerous_host(parsed.hostname):
2921
+ raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2922
+ return url
2908
2923
 
2909
- if not allow_local:
2910
- if clean_host in ("localhost", "0.0.0.0", "127.0.0.1", "::1"):
2911
- return True
2912
- if clean_host.endswith(".local") or clean_host.endswith(".internal"):
2913
- return True
2914
2924
 
2915
- return False
2925
+ class _ValidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
2926
+ """Sigue redirecciones solo si el destino supera la misma validación anti-SSRF que la URL pedida."""
2927
+ max_redirections = WEB_FETCH_MAX_REDIRECTS
2928
+
2929
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
2930
+ _validate_web_url(newurl)
2931
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
2932
+
2933
+
2934
+ _WEB_OPENER = urllib.request.build_opener(_ValidatingRedirectHandler)
2935
+
2936
+
2937
+ def _read_limited_body(stream, content_encoding: str, max_bytes: int) -> tuple[bytes, bool]:
2938
+ """Lee el cuerpo ya descomprimido hasta `max_bytes`; indica si había más (truncado)."""
2939
+ encoding = (content_encoding or "").strip().lower()
2940
+ # MAX_WBITS | 32 detecta la cabecera gzip o zlib.
2941
+ decompressor = zlib.decompressobj(zlib.MAX_WBITS | 32) if encoding in ("gzip", "x-gzip", "deflate") else None
2942
+ data = bytearray()
2943
+ while len(data) <= max_bytes:
2944
+ chunk = stream.read(WEB_FETCH_READ_CHUNK)
2945
+ if not chunk:
2946
+ break
2947
+ if decompressor:
2948
+ # La salida se limita al hueco restante: un cuerpo comprimido no puede crecer sin tope.
2949
+ chunk = decompressor.decompress(chunk, max_bytes + 1 - len(data))
2950
+ data += chunk
2951
+ truncated = len(data) > max_bytes
2952
+ return bytes(data[:max_bytes]), truncated
2953
+
2954
+
2955
+ def is_text_content_type(content_type: str) -> bool:
2956
+ """Tipos que se devuelven como texto; el resto viaja en base64."""
2957
+ media_type = (content_type or "").split(";", 1)[0].strip().lower()
2958
+ return not media_type or bool(TEXT_CONTENT_TYPE_RE.match(media_type))
2916
2959
 
2917
2960
 
2918
2961
  def fetch_web_resource(params: dict) -> dict:
@@ -2920,97 +2963,48 @@ def fetch_web_resource(params: dict) -> dict:
2920
2963
  url = params.get("url")
2921
2964
  if not isinstance(url, str) or not url.strip():
2922
2965
  raise ValueError("URL no válida o ausente.")
2923
- url = url.strip()
2924
- parsed = urllib.parse.urlparse(url)
2925
- if parsed.scheme not in ("http", "https"):
2926
- raise ValueError(f"Protocolo no permitido: '{parsed.scheme}'. Solo se admite http y https.")
2927
- hostname = (parsed.hostname or "").lower().strip("[]")
2928
- if not hostname:
2929
- raise ValueError("URL sin nombre de host válido.")
2966
+ url = _validate_web_url(url.strip())
2930
2967
 
2931
- allow_local = bool(params.get("allow_local", False))
2932
- if is_dangerous_host(hostname, allow_local):
2933
- raise PermissionError("Acceso bloqueado: intento de acceso a servicios internos o metadatos de infraestructura (SSRF).")
2934
-
2935
- timeout_s = float(params.get("timeout_s", 10.0))
2936
- timeout_s = max(0.5, min(timeout_s, 30.0))
2937
- max_bytes = int(params.get("max_bytes", 2 * 1024 * 1024))
2938
- max_bytes = max(1024, min(max_bytes, 10 * 1024 * 1024))
2968
+ timeout_s = max(0.5, min(float(params.get("timeout_s", 10.0)), 30.0))
2969
+ max_bytes = max(1024, min(int(params.get("max_bytes", 2 * 1024 * 1024)), WEB_FETCH_MAX_BYTES))
2939
2970
  as_binary = bool(params.get("binary", False))
2940
-
2971
+ accept = params.get("accept")
2941
2972
  req_headers = {
2942
2973
  "User-Agent": DEFAULT_USER_AGENT,
2943
- "Accept": "text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.8",
2974
+ "Accept": accept if isinstance(accept, str) and accept else "*/*",
2944
2975
  "Accept-Encoding": "gzip, deflate",
2945
2976
  }
2946
- custom_headers = params.get("headers")
2947
- if isinstance(custom_headers, dict):
2948
- for k, v in custom_headers.items():
2949
- if isinstance(k, str) and isinstance(v, str) and k.lower() != "host":
2950
- req_headers[k] = v
2951
2977
 
2952
2978
  req = urllib.request.Request(url, headers=req_headers)
2953
2979
  try:
2954
- with urllib.request.urlopen(req, timeout=timeout_s) as resp:
2955
- status = resp.status
2980
+ with _WEB_OPENER.open(req, timeout=timeout_s) as resp:
2956
2981
  content_type = resp.headers.get("Content-Type", "")
2957
- content_encoding = resp.headers.get("Content-Encoding", "").lower()
2958
- raw_data = resp.read(max_bytes + 1)
2959
- truncated = len(raw_data) > max_bytes
2960
- if truncated:
2961
- raw_data = raw_data[:max_bytes]
2962
-
2963
- if "gzip" in content_encoding:
2964
- try:
2965
- raw_data = gzip.decompress(raw_data)
2966
- except (OSError, gzip.BadGzipFile):
2967
- pass
2968
- elif "deflate" in content_encoding:
2969
- try:
2970
- raw_data = zlib.decompress(raw_data)
2971
- except (OSError, zlib.error):
2972
- pass
2973
-
2974
- resp_headers = {k: v for k, v in resp.getheaders()}
2975
-
2976
- if as_binary:
2977
- return {
2978
- "ok": True,
2979
- "status": status,
2980
- "content_type": content_type,
2981
- "headers": resp_headers,
2982
- "base64": base64.b64encode(raw_data).decode("ascii"),
2983
- "byte_size": len(raw_data),
2984
- "truncated": truncated
2985
- }
2986
-
2982
+ raw_data, truncated = _read_limited_body(resp, resp.headers.get("Content-Encoding", ""), max_bytes)
2983
+ # Las cabeceras de transporte ya no describen el cuerpo descomprimido.
2984
+ resp_headers = {k: v for k, v in resp.getheaders() if k.lower() not in ("content-encoding", "content-length")}
2985
+ result = {
2986
+ "ok": True,
2987
+ "status": resp.status,
2988
+ "url": resp.geturl(),
2989
+ "content_type": content_type,
2990
+ "headers": resp_headers,
2991
+ "byte_size": len(raw_data),
2992
+ "truncated": truncated
2993
+ }
2994
+ if as_binary or not is_text_content_type(content_type):
2995
+ result["base64"] = base64.b64encode(raw_data).decode("ascii")
2996
+ return result
2987
2997
  charset = resp.headers.get_content_charset() or "utf-8"
2988
2998
  try:
2989
- text_content = raw_data.decode(charset, errors="replace")
2990
- return {
2991
- "ok": True,
2992
- "status": status,
2993
- "content_type": content_type,
2994
- "headers": resp_headers,
2995
- "text": text_content,
2996
- "byte_size": len(raw_data),
2997
- "truncated": truncated
2998
- }
2999
- except (UnicodeDecodeError, LookupError):
3000
- return {
3001
- "ok": True,
3002
- "status": status,
3003
- "content_type": content_type,
3004
- "headers": resp_headers,
3005
- "base64": base64.b64encode(raw_data).decode("ascii"),
3006
- "byte_size": len(raw_data),
3007
- "truncated": truncated
3008
- }
2999
+ result["text"] = raw_data.decode(charset, errors="replace")
3000
+ except LookupError:
3001
+ result["text"] = raw_data.decode("utf-8", errors="replace")
3002
+ return result
3009
3003
  except urllib.error.HTTPError as he:
3010
3004
  err_body = ""
3011
3005
  try:
3012
3006
  err_body = he.read(2048).decode("utf-8", errors="replace")
3013
- except (OSError, UnicodeDecodeError):
3007
+ except OSError:
3014
3008
  pass
3015
3009
  return {
3016
3010
  "ok": False,
@@ -3432,8 +3426,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
3432
3426
  return None, {"code": -32602, "message": str(exc)}, ""
3433
3427
  if method == "zerochat/diff/open":
3434
3428
  try:
3435
- commit_fallback = bool(params.get("commit_fallback") or params.get("commitFallback"))
3436
- return open_file_diff(params.get("path"), params.get("tool"), commit_fallback), None, ""
3429
+ return open_file_diff(params.get("path"), bool(params.get("commit_fallback"))), None, ""
3437
3430
  except (ValueError, RuntimeError) as exc:
3438
3431
  return None, {"code": -32602, "message": str(exc)}, ""
3439
3432
  except Exception as exc:
File without changes
File without changes
File without changes