Files

1 line
6.4 KiB
JSON

{"repo": "adelaidasofia/travel-mcp", "files": {"audit.py": {"2eb207b4c833f96cd07bef9d219f3345a6014198": {"t": 1785674454.3983095, "n": 6000, "h": "3b145e74ec2f607d", "c": "\"\"\"4-field observability + sanitize_error for travel-mcp.\n\nPer MCP Build Runbook §\"Per-call observability — 4-field schema\":\n execution_time_ms : int\n io : dict {\"input\": ..., \"output\": ...}\n token_usage : dict {\"prompt\", \"completion\", \"cache_read\", \"cache_creation\"}\n error_class : str | None\n\nPlus sanitize_error() per url-input-safety.md §\"sanitize_error strip patterns\":\n strips api keys / tokens / Bearer headers / passwords from any string crossing\n the user-visible / model-visible boundary.\n\nAppend-only JSONL at $HOME/.claude/travel-mcp/audit.log.jsonl. One line per call.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport time\nfrom collections.abc import Iterator\nfrom contextlib import contextmanager\nfrom pathlib import Path\nfrom typing import Any\n\nAUDIT_PATH = Path(os.environ.get(\"TRAVEL_MCP_AUDIT_PATH\") or\n os.path.expanduser(\"~/.claude/travel-mcp/audit.log.jsonl\"))\n\n# Patterns scrubbed from any string before it crosses the seam.\n# Ordered most-specific → least-specific so longer prefixes shadow shorter ones.\n_SCRUB_PATTERNS: list[tuple[re.Pattern[str], str]] = [\n (re.compile(r\"sk-ant-[A-Za-z0-9_\\-]{20,}\"), \"sk-ant-***REDACTED***\"),\n (re.compile(r\"sk-proj-[A-Za-z0-9_\\-]{20,}\"), \"sk-proj-***REDACTED***\"),\n (re.compile(r\"ghp_[A-Za-z0-9_]{20,}\"), \"ghp_***REDACTED***\"),\n (re.compile(r\"github_pat_[A-Za-z0-9_]{20,}\"), \"github_pat_***REDACTED***\"),\n (re.compile(r\"AKIA[0-9A-Z]{16}\"), \"AKIA***REDACTED***\"),\n (re.compile(r\"npm_[A-Za-z0-9]{30,}\"), \"npm_***REDACTED***\"),\n (re.compile(r\"sk_live_[0-9a-zA-Z]{20,}\"), \"sk_live_***REDACTED***\"),\n (re.compile(r\"(?i)\\bBearer\\s+[A-Za-z0-9_\\-.=]+\"), \"Bearer ***REDACTED***\"),\n (re.compile(r\"(?i)\\bAuthorization\\s*[:=]\\s*\\S+\"), \"Authorization: ***REDACTED***\"),\n (re.compile(r\"(?i)\\bX-Api-Key\\s*[:=]\\s*\\S+\"), \"X-Api-Key: ***REDACTED***\"),\n (re.compile(r\"(?i)\\bapi[_\\-]?key\\s*[:=]\\s*\\S+\"), \"api_key=***REDACTED***\"),\n (re.compile(r\"(?i)\\btoken\\s*[:=]\\s*\\S+\"), \"token=***REDACTED***\"),\n (re.compile(r\"(?i)\\bsecret\\s*[:=]\\s*\\S+\"), \"secret=***REDACTED***\"),\n (re.compile(r\"(?i)\\bpassword\\s*[:=]\\s*\\S+\"), \"password=***REDACTED***\"),\n]\n\n\ndef sanitize_error(text: Any) -> str:\n \"\"\"Strip credentials from any string before emitting.\"\"\"\n if not isinstance(text, str):\n text = str(text)\n cleaned = text\n for pattern, replacement in _SCRUB_PATTERNS:\n cleaned = pattern.sub(replacement, cleaned)\n return cleaned\n\n\ndef sanitize_payload(obj: Any) -> Any:\n \"\"\"Recursively sanitize a dict/list/str payload for the audit io field.\"\"\"\n if isinstance(obj, dict):\n return {k: sanitize_payload(v) for k, v in obj.items()}\n if isinstance(obj, list):\n return [sanitize_payload(v) for v in obj]\n if isinstance(obj, str):\n return sanitize_error(obj)\n return obj\n\n\ndef classify_error(exc: BaseException) -> str:\n \"\"\"Map an exception to a stable error_class for downstream taxonomy.\"\"\"\n name = type(exc).__name__\n if name == \"ValidationError\" or name == \"ValueError\":\n return \"validation\"\n if name in {\"TimeoutError\", \"ReadTimeout\", \"ConnectTimeout\"}:\n return \"timeout\"\n if name in {\"PermissionError\", \"OSError\", \"FileNotFoundError\", \"IsADirectoryError\"}:\n return \"filesystem\"\n if name in {\"RuntimeError\"} and \"auth\" in str(exc).lower():\n return \"auth\"\n if name == \"RuntimeError\":\n return \"internal_error\"\n return \"upstream_error\"\n\n\ndef _ensure_dir() -> None:\n AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)\n\n\ndef record(\n tool: str,\n *,\n execution_time_ms: int,\n io: dict[str, Any],\n token_usage: dict[str, int] | None = None,\n error_class: str | None = None,\n extra: dict[str, Any] | None = None,\n) -> None:\n \"\"\"Append a JSONL line. Best-effort: never raises into the caller.\"\"\"\n payload = {\n \"ts\": time.strftime(\"%Y-%m-%dT%H:%M:%S%z\", time.localtime()),\n \"tool\": tool,\n \"execution_time_ms\": int(execution_time_ms),\n \"io\": sanitize_payload(io),\n \"token_usage\": token_usage or {\n \"prompt\": 0, \"completion\": 0, \"cache_read\": 0, \"cache_creation\": 0,\n },\n \"error_class\": error_class,\n }\n if extra:\n payload[\"extra\"] = sanitize_payload(extra)\n try:\n _ensure_dir()\n with AUDIT_PATH.open(\"a\", encoding=\"utf-8\") as f:\n f.write(json.dumps(payload, ensure_ascii=False) + \"\\n\")\n except Exception:\n # Audit must never break the tool call. Silent failure here is acceptable.\n pass\n\n\n@contextmanager\ndef timed(tool: str, *, input_payload: dict[str, Any]) -> Iterator[dict[str, Any]]:\n \"\"\"Context manager that records audit line on __exit__.\n\n Usage:\n with timed(\"analyze_route\", input_payload={...}) as ctx:\n result = ...\n ctx[\"output\"] = result\n ctx[\"token_usage\"] = {...} # optional\n ctx[\"extra\"] = {...} # optional\n \"\"\"\n start = time.perf_counter()\n ctx: dict[str, Any] = {\n \"output\": None,\n \"token_usage\": None,\n \"extra\": None,\n \"error_class\": None,\n }\n try:\n yield ctx\n except BaseException as exc:\n ctx[\"error_class\"] = classify_error(exc)\n ctx[\"output\"] = {\"error\": sanitize_error(str(exc))}\n elapsed_ms = int((time.perf_counter() - start) * 1000)\n record(\n tool,\n execution_time_ms=elapsed_ms,\n io={\"input\": input_payload, \"output\": ctx[\"output\"]},\n token_usage=ctx[\"token_usage\"],\n error_class=ctx[\"error_class\"],\n extra=ctx[\"extra\"],\n )\n raise\n elapsed_ms = int((time.perf_counter() - start) * 1000)\n record(\n tool,\n execution_time_ms=elapsed_ms,\n io={\"input\": input_payload, \"output\": ctx[\"output\"]},\n token_usage=ctx[\"token_usage\"],\n error_class=ctx[\"error_class\"],\n extra=ctx[\"extra\"],\n )\n"}}}}