zerochat 7.7.0__tar.gz → 7.8.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zerochat
3
- Version: 7.7.0
3
+ Version: 7.8.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 = "7.7.0"
7
+ version = "7.8.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: 7.7.0
3
+ Version: 7.8.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
@@ -37,7 +37,7 @@ import webbrowser
37
37
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
38
38
  from pathlib import Path
39
39
 
40
- SOURCE_BACKEND_VERSION = "7.7.0"
40
+ SOURCE_BACKEND_VERSION = "7.8.0"
41
41
 
42
42
  def _read_source_version(filename: str) -> str | None:
43
43
  """Lee la versión de un archivo del repositorio cuando se ejecuta desde fuentes."""
@@ -388,8 +388,8 @@ def check_version():
388
388
  # Herramientas Locales Core
389
389
  # ==============================================================================
390
390
 
391
- def list_directory(path: str = ".", max_depth: int = 1) -> str:
392
- """Recorre un directorio local y devuelve la lista de archivos y carpetas."""
391
+ def list_directory(path: str = ".") -> str:
392
+ """Lista los archivos y carpetas de un directorio, sin recorrer subdirectorios."""
393
393
  try:
394
394
  target = Path(path).expanduser().resolve()
395
395
  if not target.exists():
@@ -544,12 +544,11 @@ def execute_command(command: str, cwd: str = ".", timeout_seconds: int = 60) ->
544
544
  LOCAL_TOOLS_DEFINITIONS = [
545
545
  {
546
546
  "name": "list_directory",
547
- "description": "Lista archivos y carpetas en un directorio local.",
547
+ "description": "Lista archivos y carpetas de un directorio local, sin recorrer subdirectorios.",
548
548
  "inputSchema": {
549
549
  "type": "object",
550
550
  "properties": {
551
- "path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"},
552
- "max_depth": {"type": "integer", "description": "Profundidad máxima", "default": 1}
551
+ "path": {"type": "string", "description": "Ruta relativa o absoluta (por defecto '.')"}
553
552
  }
554
553
  }
555
554
  },
@@ -607,6 +606,54 @@ LOCAL_TOOL_HANDLERS = {
607
606
  # Servidor HTTP JSON-RPC 2.0 y SSE con Autenticación por Token
608
607
  # ==============================================================================
609
608
 
609
+ MAX_HTTP_BODY_BYTES = 1024 * 1024
610
+ MAX_RPC_METHOD_LENGTH = 128
611
+ MAX_TOOL_NAME_LENGTH = 128
612
+ MAX_PATH_LENGTH = 4096
613
+ MAX_COMMAND_LENGTH = 16384
614
+
615
+
616
+ def validate_local_tool_arguments(tool_name: str, arguments: dict) -> str | None:
617
+ """Valida tipos y límites de las herramientas locales antes de ejecutarlas."""
618
+ schemas = {
619
+ "list_directory": {"path": (str, MAX_PATH_LENGTH)},
620
+ "read_file": {
621
+ "path": (str, MAX_PATH_LENGTH), "start_line": (int, None),
622
+ "max_lines": (int, None), "max_bytes": (int, None)
623
+ },
624
+ "edit_file": {
625
+ "path": (str, MAX_PATH_LENGTH), "content": (str, MAX_HTTP_BODY_BYTES),
626
+ "mode": (str, 32), "target_content": (str, MAX_HTTP_BODY_BYTES)
627
+ },
628
+ "execute_command": {
629
+ "command": (str, MAX_COMMAND_LENGTH), "cwd": (str, MAX_PATH_LENGTH),
630
+ "timeout_seconds": (int, None)
631
+ }
632
+ }
633
+ schema = schemas.get(tool_name)
634
+ if schema is None:
635
+ return None
636
+
637
+ for name, value in arguments.items():
638
+ expected = schema.get(name)
639
+ if expected is None:
640
+ return f"Argumento no permitido: {name}"
641
+ expected_type, max_length = expected
642
+ if isinstance(value, bool) or not isinstance(value, expected_type):
643
+ return f"Tipo inválido para '{name}'"
644
+ if max_length is not None and len(value) > max_length:
645
+ return f"'{name}' excede el tamaño máximo permitido"
646
+
647
+ required = {
648
+ "read_file": ("path",),
649
+ "edit_file": ("path", "content"),
650
+ "execute_command": ("command",)
651
+ }
652
+ for name in required.get(tool_name, ()):
653
+ if name not in arguments:
654
+ return f"Falta el argumento obligatorio: {name}"
655
+ return None
656
+
610
657
  def sanitize_log_path(raw_path: str) -> str:
611
658
  """Oculta tokens de sesión o parámetros sensibles en la query string para logs seguros."""
612
659
  if not raw_path or "?" not in raw_path:
@@ -626,10 +673,10 @@ def format_log_error(msg: str, max_len: int = 160) -> str:
626
673
  return cleaned
627
674
 
628
675
 
629
- def is_allowed_origin(origin: str | None) -> bool:
676
+ def is_allowed_origin(origin: str | None, require_origin: bool = False) -> bool:
630
677
  """Verifica si el origen CORS está autorizado."""
631
678
  if origin is None or origin == "null":
632
- return True
679
+ return not require_origin
633
680
  origin_lower = origin.lower()
634
681
  if origin_lower == "https://albalday.github.io" or origin_lower.startswith("https://albalday.github.io/"):
635
682
  return True
@@ -1379,7 +1426,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1379
1426
 
1380
1427
  def send_cors_headers(self):
1381
1428
  origin = self.headers.get("Origin")
1382
- if not is_allowed_origin(origin):
1429
+ if not is_allowed_origin(origin, require_origin=True):
1383
1430
  return
1384
1431
  self.send_header("Access-Control-Allow-Origin", origin if origin else "*")
1385
1432
  self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
@@ -1387,19 +1434,13 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1387
1434
  self.send_header("Access-Control-Allow-Private-Network", "true")
1388
1435
 
1389
1436
  def verify_token(self) -> bool:
1390
- """Comprueba el token efímero de sesión en cabeceras o query string."""
1437
+ """Comprueba el token de sesión exclusivamente en cabeceras HTTP."""
1391
1438
  auth_header = self.headers.get("Authorization", "")
1392
1439
  token_candidate = None
1393
1440
  if auth_header.startswith("Bearer "):
1394
1441
  token_candidate = auth_header[7:].strip()
1395
1442
  elif "X-ZeroChat-Token" in self.headers:
1396
1443
  token_candidate = self.headers.get("X-ZeroChat-Token", "").strip()
1397
- elif "?" in self.path:
1398
- query = self.path.split("?", 1)[1]
1399
- for part in query.split("&"):
1400
- if part.startswith("token="):
1401
- token_candidate = part.split("=", 1)[1].strip()
1402
- break
1403
1444
 
1404
1445
  if not token_candidate:
1405
1446
  return False
@@ -1416,7 +1457,7 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1416
1457
  if not is_heartbeat:
1417
1458
  self._log_req("OPTIONS", safe_path)
1418
1459
  origin = self.headers.get("Origin")
1419
- if not is_allowed_origin(origin):
1460
+ if not is_allowed_origin(origin, require_origin=True):
1420
1461
  self.send_response(403)
1421
1462
  self.end_headers()
1422
1463
  self._log_res(403, safe_path, (time.monotonic() - t0) * 1000, f"Origen no permitido: '{origin}'")
@@ -1534,8 +1575,25 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1534
1575
  self._log_res(401, safe_path, (time.monotonic() - t0) * 1000, "Token de sesión ausente o inválido")
1535
1576
  return
1536
1577
 
1537
- content_len = int(self.headers.get("Content-Length", 0))
1538
- post_data = self.rfile.read(content_len) if content_len > 0 else b"{}"
1578
+ raw_content_len = self.headers.get("Content-Length")
1579
+ try:
1580
+ content_len = int(raw_content_len)
1581
+ except (TypeError, ValueError):
1582
+ self._log_req("POST", safe_path)
1583
+ self._send_json_response(400, {"error": "Invalid Content-Length"})
1584
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length inválido")
1585
+ return
1586
+ if content_len < 0:
1587
+ self._log_req("POST", safe_path)
1588
+ self._send_json_response(400, {"error": "Invalid Content-Length"})
1589
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Content-Length negativo")
1590
+ return
1591
+ if content_len > MAX_HTTP_BODY_BYTES:
1592
+ self._log_req("POST", safe_path)
1593
+ self._send_json_response(413, {"error": "Request body too large"})
1594
+ self._log_res(413, safe_path, (time.monotonic() - t0) * 1000, "Cuerpo HTTP excede el límite")
1595
+ return
1596
+ post_data = self.rfile.read(content_len)
1539
1597
 
1540
1598
  try:
1541
1599
  req = json.loads(post_data.decode("utf-8"))
@@ -1554,10 +1612,49 @@ class ZeroChatServerHandler(BaseHTTPRequestHandler):
1554
1612
  self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, f"Error parseando JSON: {err}")
1555
1613
  return
1556
1614
 
1557
- req_path = self.path.split("?", 1)[0].rstrip("/")
1615
+ if not isinstance(req, dict):
1616
+ self._send_json_response(400, {"error": "Request must be a JSON object"})
1617
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "La solicitud JSON no es un objeto")
1618
+ return
1619
+
1558
1620
  req_id = req.get("id")
1559
1621
  method = req.get("method")
1560
1622
  params = req.get("params", {})
1623
+ if req.get("jsonrpc") != "2.0":
1624
+ self._send_json_response(400, {"error": "Invalid JSON-RPC version"})
1625
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Versión JSON-RPC inválida")
1626
+ return
1627
+ if req_id is not None and (isinstance(req_id, bool) or not isinstance(req_id, (str, int, float))):
1628
+ self._send_json_response(400, {"error": "Invalid JSON-RPC id"})
1629
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "id JSON-RPC inválido")
1630
+ return
1631
+ if not isinstance(method, str) or not method or len(method) > MAX_RPC_METHOD_LENGTH:
1632
+ self._send_json_response(400, {"error": "Invalid JSON-RPC method"})
1633
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Método JSON-RPC inválido")
1634
+ return
1635
+ if not isinstance(params, dict):
1636
+ self._send_json_response(400, {"error": "Invalid JSON-RPC params"})
1637
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "params JSON-RPC inválidos")
1638
+ return
1639
+
1640
+ if method == "tools/call":
1641
+ tool_name = params.get("name")
1642
+ tool_args = params.get("arguments", {})
1643
+ if not isinstance(tool_name, str) or not tool_name or len(tool_name) > MAX_TOOL_NAME_LENGTH:
1644
+ self._send_json_response(400, {"error": "Invalid tool name"})
1645
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Nombre de herramienta inválido")
1646
+ return
1647
+ if not isinstance(tool_args, dict):
1648
+ self._send_json_response(400, {"error": "Invalid tool arguments"})
1649
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, "Argumentos de herramienta inválidos")
1650
+ return
1651
+ tool_args_error = validate_local_tool_arguments(tool_name, tool_args)
1652
+ if tool_args_error:
1653
+ self._send_json_response(400, {"error": tool_args_error})
1654
+ self._log_res(400, safe_path, (time.monotonic() - t0) * 1000, tool_args_error)
1655
+ return
1656
+
1657
+ req_path = self.path.split("?", 1)[0].rstrip("/")
1561
1658
 
1562
1659
  # Determinar etiqueta de seguimiento para la petición y respuesta (sin datos sensibles)
1563
1660
  tool_name = params.get("name", "") if isinstance(params, dict) else ""
File without changes
File without changes
File without changes