diff --git a/mcp/server.py b/mcp/server.py index 3cd935be..bf84dd24 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -93,7 +93,14 @@ def _handle_tools_call(req_id: Any, params: dict) -> dict: result = tool["_handler"](args) except Exception as exc: log.exception("Tool %s raised an exception", name) - return _err(req_id, _INTERNAL_ERROR, str(exc)) + # The exception's class name (e.g. "ValidationError", "TimeoutError") + # is safe to surface — unlike str(exc), it never carries paths, + # connection strings, or other internal detail — and lets the + # client distinguish failure kinds without a full message. + return _err( + req_id, _INTERNAL_ERROR, + f"Tool '{name}' failed ({type(exc).__name__}). See server logs for details.", + ) # MCP spec: content must be a list of content items return _ok(req_id, { @@ -171,7 +178,10 @@ class SemanticaMCPServer: log.exception("Unhandled error in method %s", method) if req_id is None: return None - return _err(req_id, _INTERNAL_ERROR, str(exc)) + return _err( + req_id, _INTERNAL_ERROR, + f"Method '{method}' failed ({type(exc).__name__}). See server logs for details.", + ) # ------------------------------------------------------------------ def run(self) -> None: diff --git a/semantica/cli.py b/semantica/cli.py index 7b944dc6..403971e0 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -20,7 +20,7 @@ if sys.platform == "win32": sys.stderr.reconfigure(encoding="utf-8", errors="replace") from dataclasses import asdict, dataclass, field, is_dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Sequence, Tuple import yaml @@ -3933,15 +3933,68 @@ def backup_restore(cli_ctx: CLIContext, source: str, local_dry: bool) -> None: try: if _tf.is_tarfile(str(work_path)): - restore_root = Path.cwd() + restore_root = Path.cwd().resolve() with _tf.open(str(work_path), "r:*") as tar: # Dry-run listing was already handled above; extract now for member in tar.getmembers(): # Strip the leading "semantica-backup/" prefix member.name = member.name.replace("semantica-backup/", "", 1) - if member.name: - tar.extract(member, path=str(restore_root)) - console.print(f" restored: {member.name}") + if not member.name: + continue + + # Reject members whose resolved path escapes the + # restore root (path traversal / absolute paths), + # regardless of the "semantica-backup/" prefix. + member_path = (restore_root / member.name).resolve() + try: + member_path.relative_to(restore_root) + except ValueError: + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "path escapes the restore directory." + ) + + # Reject symlink/hardlink members whose target + # escapes the restore root. Checked two ways: + # lexically (linkname itself, so an absolute path or + # a literal ".." segment is rejected outright, with + # no dependence on what else does or doesn't already + # exist on disk) and by resolution (catches any + # remaining traversal the lexical check misses). + if member.issym() or member.islnk(): + linkname = member.linkname or "" + linkname_parts = PurePosixPath( + linkname.replace("\\", "/") + ).parts + if ( + not linkname + or os.path.isabs(linkname) + or PureWindowsPath(linkname).is_absolute() + or ".." in linkname_parts + ): + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "link target is absolute or traverses " + "out of the archive." + ) + link_target = ( + member_path.parent / linkname + ).resolve() + try: + link_target.relative_to(restore_root) + except ValueError: + raise click.ClickException( + f"Refusing to restore '{member.name}': " + "link target escapes the restore directory." + ) + + extract_kwargs: Dict[str, Any] = {"path": str(restore_root)} + if hasattr(_tf, "data_filter"): + # Python >=3.12: also reject device files, and + # further harden the traversal/ownership checks. + extract_kwargs["filter"] = "data" + tar.extract(member, **extract_kwargs) + console.print(f" restored: {member.name}") elif src.is_dir(): restore_root = Path.cwd() for f in src.rglob("*"): diff --git a/semantica/export/report_generator.py b/semantica/export/report_generator.py index d4b67920..fcee11a1 100644 --- a/semantica/export/report_generator.py +++ b/semantica/export/report_generator.py @@ -23,6 +23,7 @@ Author: Semantica Contributors License: MIT """ +import html import json from datetime import datetime from pathlib import Path @@ -362,7 +363,7 @@ class ReportGenerator: ' ' ) title = data.get("title", "Report") - lines.append(f"