for each line)
+ def _pre_repl(match: re.Match[str]) -> str:
+ pre = match.group(0)
+ pre = re.sub(r"
\s*", "\n", pre, flags=re.I)
+ pre = re.sub(r"]*>", "", pre, flags=re.I)
+ pre = re.sub(r"
", "\n", pre, flags=re.I)
+ pre = re.sub(r"<[^>]+>", "", pre)
+ return "\n```\n" + _html_unescape(pre).strip() + "\n```\n"
+
+ cleaned = re.sub(r"
]*>.*?
", _pre_repl, cleaned, flags=re.S | re.I)
+
+ # Newlines for common block tags
+ cleaned = re.sub(
+ r"(p|h1|h2|h3|h4|h5|h6|li|blockquote)>", "\n", cleaned, flags=re.I
+ )
+ cleaned = re.sub(r"
", "\n", cleaned, flags=re.I)
+ cleaned = re.sub(r"
]*>", "\n---\n", cleaned, flags=re.I)
+
+ # Strip remaining tags
+ cleaned = re.sub(r"<[^>]+>", "", cleaned)
+
+ text = _html_unescape(cleaned)
+ lines = [ln.rstrip() for ln in text.splitlines()]
+ # Collapse excessive blank lines
+ out: List[str] = []
+ blank = False
+ for ln in lines:
+ if ln.strip() == "":
+ if blank:
+ continue
+ blank = True
+ out.append("")
+ continue
+ blank = False
+ out.append(ln.strip())
+ return "\n".join(out).strip()
+
+
+def _html_unescape(text: str) -> str:
+ # Avoid importing html module repeatedly; do it lazily.
+ import html as _html # local import to keep global import list small
+
+ return _html.unescape(text)
+
+
+def _discover_routes_from_docs_index() -> List[str]:
+ html = _http_get(AKASH_DOCS_BASE + "/")
+ hrefs = set(re.findall(r'href=\"(/docs/[^\"#?]+)\"', html))
+ routes: List[str] = []
+ for href in sorted(hrefs):
+ route, _url = _normalize_route(href)
+ if route:
+ routes.append(route)
+ return routes
+
+
+@dataclass(frozen=True)
+class CachedDoc:
+ cache_key: str
+ fetched_at: str
+ source: str
+ route: str
+ url: str
+ ref: str
+ content_path: str
+
+
+class DocStore:
+ def __init__(self, root_dir: Path) -> None:
+ self.root_dir = root_dir
+ self.pages_dir = root_dir / "pages"
+ self.index_path = root_dir / "index.json"
+ self.pages_dir.mkdir(parents=True, exist_ok=True)
+ self._index: Dict[str, Dict[str, Any]] = {}
+ if self.index_path.exists():
+ try:
+ self._index = json.loads(self.index_path.read_text(encoding="utf-8"))
+ except Exception:
+ self._index = {}
+
+ def _write_index(self) -> None:
+ tmp = self.index_path.with_suffix(".tmp")
+ tmp.write_text(
+ json.dumps(self._index, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ tmp.replace(self.index_path)
+
+ def get(self, cache_key: str) -> Optional[CachedDoc]:
+ raw = self._index.get(cache_key)
+ if not raw:
+ return None
+ path = Path(raw.get("content_path") or "")
+ if not path.exists():
+ return None
+ return CachedDoc(
+ cache_key=cache_key,
+ fetched_at=str(raw.get("fetched_at") or ""),
+ source=str(raw.get("source") or ""),
+ route=str(raw.get("route") or ""),
+ url=str(raw.get("url") or ""),
+ ref=str(raw.get("ref") or ""),
+ content_path=str(path),
+ )
+
+ def save(
+ self,
+ *,
+ cache_key: str,
+ source: str,
+ route: str,
+ url: str,
+ ref: str,
+ content: str,
+ ) -> CachedDoc:
+ content_hash = _sha256_hex(f"{source}:{ref}:{url}")[:20]
+ path = self.pages_dir / f"{content_hash}.txt"
+ path.write_text(content, encoding="utf-8")
+ entry = {
+ "fetched_at": _utc_now_iso(),
+ "source": source,
+ "route": route,
+ "url": url,
+ "ref": ref,
+ "content_path": str(path),
+ }
+ self._index[cache_key] = entry
+ self._write_index()
+ return self.get(cache_key) or CachedDoc(
+ cache_key=cache_key,
+ fetched_at=entry["fetched_at"],
+ source=source,
+ route=route,
+ url=url,
+ ref=ref,
+ content_path=str(path),
+ )
+
+
+def _default_state_dir() -> Path:
+ return _repo_root() / "archive_runtime" / "akash_docs_mcp"
+
+
+def _truncate_to_max_bytes(text: str, *, max_bytes: int) -> Tuple[str, bool]:
+ blob = text.encode("utf-8")
+ if len(blob) <= max_bytes:
+ return text, False
+ # Reserve a bit for the truncation notice
+ reserve = min(512, max_bytes // 10)
+ head = blob[: max(0, max_bytes - reserve)].decode("utf-8", "replace")
+ head = head.rstrip() + "\n\n[TRUNCATED: response exceeded VM_MCP_MAX_BYTES]\n"
+ return head, True
+
+
+def _mcp_text_result(text: str, *, is_error: bool = False) -> Dict[str, Any]:
+ text, _truncated = _truncate_to_max_bytes(text, max_bytes=_max_bytes())
+ result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
+ if is_error:
+ result["isError"] = True
+ return result
+
+
+class AkashDocsTools:
+ def __init__(self) -> None:
+ state_dir = Path(os.getenv("VM_AKASH_DOCS_MCP_STATE_DIR") or _default_state_dir())
+ self.store = DocStore(state_dir)
+
+ def akash_docs_list_routes(self) -> Dict[str, Any]:
+ routes = _discover_routes_from_docs_index()
+ return {
+ "ok": True,
+ "summary": f"Discovered {len(routes)} docs route(s) from {AKASH_DOCS_BASE}/.",
+ "data": {"routes": routes},
+ "next_steps": ["akash_docs_fetch(route_or_url=...)"],
+ }
+
+ def akash_docs_fetch(
+ self,
+ *,
+ route_or_url: str,
+ source: str = "auto",
+ ref: str = AKASH_DOCS_GITHUB_REF_DEFAULT,
+ max_chars: int = 12_000,
+ refresh: bool = False,
+ strip_frontmatter: bool = True,
+ ) -> Dict[str, Any]:
+ route, canonical_url = _normalize_route(route_or_url)
+ source_norm = (source or "auto").strip().lower()
+ if source_norm not in ("auto", "github", "site"):
+ raise ValueError("source must be one of: auto, github, site")
+
+ max_chars_int = max(0, int(max_chars))
+ # Avoid flooding clients; open content_path for full content.
+ max_chars_int = min(max_chars_int, max(2_000, _max_bytes() - 8_000))
+
+ cache_key = f"{source_norm}:{ref}:{route or canonical_url}"
+ cached = self.store.get(cache_key)
+ if cached and not refresh:
+ content = Path(cached.content_path).read_text(encoding="utf-8")
+ if strip_frontmatter and cached.source == "github":
+ content = _strip_frontmatter(content)
+ truncated = len(content) > max_chars_int
+ return {
+ "ok": True,
+ "summary": "Returned cached docs content.",
+ "data": {
+ "source": cached.source,
+ "route": cached.route,
+ "url": cached.url,
+ "ref": cached.ref,
+ "cached": True,
+ "fetched_at": cached.fetched_at,
+ "content": content[:max_chars_int],
+ "truncated": truncated,
+ "content_path": cached.content_path,
+ },
+ "next_steps": ["Set refresh=true to refetch."],
+ }
+
+ attempted: List[Dict[str, Any]] = []
+
+ def _try_github() -> Optional[Tuple[str, str, str]]:
+ try:
+ md, raw_url, repo_path = _fetch_markdown_from_github(route, ref=ref)
+ return md, raw_url, repo_path
+ except urllib.error.HTTPError as e:
+ attempted.append({"source": "github", "status": getattr(e, "code", None), "detail": str(e)})
+ return None
+
+ def _try_site() -> Optional[Tuple[str, str]]:
+ try:
+ html = _http_get(canonical_url)
+ article = _extract_article_html(html)
+ text = _html_to_text(article)
+ return text, canonical_url
+ except urllib.error.HTTPError as e:
+ attempted.append({"source": "site", "status": getattr(e, "code", None), "detail": str(e)})
+ return None
+
+ content: str
+ final_source: str
+ final_url: str
+ extra: Dict[str, Any] = {}
+
+ if source_norm in ("auto", "github"):
+ gh = _try_github()
+ if gh:
+ content, final_url, repo_path = gh
+ final_source = "github"
+ extra["repo_path"] = repo_path
+ elif source_norm == "github":
+ raise ValueError("GitHub fetch failed; try source='site' or verify the route/ref.")
+ else:
+ site = _try_site()
+ if not site:
+ raise ValueError(f"Fetch failed for route_or_url={route_or_url!r}. Attempts: {attempted}")
+ content, final_url = site
+ final_source = "site"
+ else:
+ site = _try_site()
+ if not site:
+ raise ValueError(f"Site fetch failed for route_or_url={route_or_url!r}. Attempts: {attempted}")
+ content, final_url = site
+ final_source = "site"
+
+ cached_doc = self.store.save(
+ cache_key=cache_key,
+ source=final_source,
+ route=route,
+ url=final_url,
+ ref=ref,
+ content=content,
+ )
+
+ content_view = content
+ if strip_frontmatter and final_source == "github":
+ content_view = _strip_frontmatter(content_view)
+ truncated = len(content_view) > max_chars_int
+ content_out = content_view[:max_chars_int]
+ return {
+ "ok": True,
+ "summary": f"Fetched docs via {final_source}.",
+ "data": {
+ "source": final_source,
+ "route": route,
+ "url": final_url,
+ "ref": ref,
+ "cached": False,
+ "fetched_at": cached_doc.fetched_at,
+ "content": content_out,
+ "truncated": truncated,
+ "content_path": cached_doc.content_path,
+ "attempts": attempted,
+ **extra,
+ },
+ "next_steps": [
+ "akash_docs_search(query=..., refresh=false)",
+ ],
+ }
+
+ def akash_docs_search(
+ self,
+ *,
+ query: str,
+ limit: int = 10,
+ refresh: bool = False,
+ ref: str = AKASH_DOCS_GITHUB_REF_DEFAULT,
+ ) -> Dict[str, Any]:
+ q = (query or "").strip()
+ if not q:
+ raise ValueError("query is required")
+ limit = max(1, min(50, int(limit)))
+
+ routes = _discover_routes_from_docs_index()
+ hits: List[Dict[str, Any]] = []
+
+ for route in routes:
+ doc = self.akash_docs_fetch(
+ route_or_url=route,
+ source="github",
+ ref=ref,
+ max_chars=0, # search reads full content from content_path
+ refresh=refresh,
+ strip_frontmatter=True,
+ )
+ data = doc.get("data") or {}
+ content_path = data.get("content_path")
+ if not content_path:
+ continue
+ try:
+ content = Path(str(content_path)).read_text(encoding="utf-8")
+ content = _strip_frontmatter(content)
+ except Exception:
+ continue
+ idx = content.lower().find(q.lower())
+ if idx == -1:
+ continue
+ start = max(0, idx - 80)
+ end = min(len(content), idx + 160)
+ snippet = content[start:end].replace("\n", " ").strip()
+ hits.append(
+ {
+ "route": route,
+ "url": data.get("url"),
+ "source": data.get("source"),
+ "snippet": snippet,
+ }
+ )
+ if len(hits) >= limit:
+ break
+
+ return {
+ "ok": True,
+ "summary": f"Found {len(hits)} hit(s) across {len(routes)} route(s).",
+ "data": {"query": q, "hits": hits, "routes_searched": len(routes)},
+ "next_steps": ["akash_docs_fetch(route_or_url=hits[0].route)"],
+ }
+
+ def akash_sdl_snippet(
+ self,
+ *,
+ service_name: str,
+ container_image: str,
+ port: int,
+ cpu_units: float = 0.5,
+ memory_size: str = "512Mi",
+ storage_size: str = "512Mi",
+ denom: str = "uakt",
+ price_amount: int = 100,
+ ) -> Dict[str, Any]:
+ svc = (service_name or "").strip()
+ img = (container_image or "").strip()
+ if not svc:
+ raise ValueError("service_name is required")
+ if not img:
+ raise ValueError("container_image is required")
+ port_int = int(port)
+ if port_int <= 0 or port_int > 65535:
+ raise ValueError("port must be 1..65535")
+
+ sdl = f"""version: \"2.0\"
+
+services:
+ {svc}:
+ image: {img}
+ expose:
+ - port: {port_int}
+ to:
+ - global: true
+
+profiles:
+ compute:
+ {svc}:
+ resources:
+ cpu:
+ units: {cpu_units}
+ memory:
+ size: {memory_size}
+ storage:
+ size: {storage_size}
+ placement:
+ akash:
+ pricing:
+ {svc}:
+ denom: {denom}
+ amount: {int(price_amount)}
+
+deployment:
+ {svc}:
+ akash:
+ profile: {svc}
+ count: 1
+"""
+ return {
+ "ok": True,
+ "summary": "Generated an Akash SDL template.",
+ "data": {
+ "service_name": svc,
+ "container_image": img,
+ "port": port_int,
+ "sdl": sdl,
+ },
+ "next_steps": [
+ "Save as deploy.yaml and deploy via Akash Console or akash CLI.",
+ ],
+ }
+
+
+TOOLS: List[Dict[str, Any]] = [
+ {
+ "name": "akash_docs_list_routes",
+ "description": "Discover common Akash docs routes by scraping https://akash.network/docs/ (SSR HTML).",
+ "inputSchema": {"type": "object", "properties": {}},
+ },
+ {
+ "name": "akash_docs_fetch",
+ "description": "Fetch an Akash docs page (prefers GitHub markdown in akash-network/website-revamp; falls back to site HTML).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "route_or_url": {"type": "string"},
+ "source": {
+ "type": "string",
+ "description": "auto|github|site",
+ "default": "auto",
+ },
+ "ref": {"type": "string", "default": AKASH_DOCS_GITHUB_REF_DEFAULT},
+ "max_chars": {"type": "integer", "default": 12000},
+ "refresh": {"type": "boolean", "default": False},
+ "strip_frontmatter": {"type": "boolean", "default": True},
+ },
+ "required": ["route_or_url"],
+ },
+ },
+ {
+ "name": "akash_docs_search",
+ "description": "Keyword search across routes discovered from /docs (fetches + caches GitHub markdown).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string"},
+ "limit": {"type": "integer", "default": 10},
+ "refresh": {"type": "boolean", "default": False},
+ "ref": {"type": "string", "default": AKASH_DOCS_GITHUB_REF_DEFAULT},
+ },
+ "required": ["query"],
+ },
+ },
+ {
+ "name": "akash_sdl_snippet",
+ "description": "Generate a minimal Akash SDL manifest for a single service exposing one port.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "service_name": {"type": "string"},
+ "container_image": {"type": "string"},
+ "port": {"type": "integer"},
+ "cpu_units": {"type": "number", "default": 0.5},
+ "memory_size": {"type": "string", "default": "512Mi"},
+ "storage_size": {"type": "string", "default": "512Mi"},
+ "denom": {"type": "string", "default": "uakt"},
+ "price_amount": {"type": "integer", "default": 100},
+ },
+ "required": ["service_name", "container_image", "port"],
+ },
+ },
+]
+
+
+class StdioJsonRpc:
+ def __init__(self) -> None:
+ self._in = sys.stdin.buffer
+ self._out = sys.stdout.buffer
+ self._mode: str | None = None # "headers" | "line"
+
+ def read_message(self) -> Optional[Dict[str, Any]]:
+ while True:
+ if self._mode == "line":
+ line = self._in.readline()
+ if not line:
+ return None
+ raw = line.decode("utf-8", "replace").strip()
+ if not raw:
+ continue
+ try:
+ msg = json.loads(raw)
+ except Exception:
+ continue
+ if isinstance(msg, dict):
+ return msg
+ continue
+
+ first = self._in.readline()
+ if not first:
+ return None
+
+ if first in (b"\r\n", b"\n"):
+ continue
+
+ # Auto-detect newline-delimited JSON framing.
+ if self._mode is None and first.lstrip().startswith(b"{"):
+ try:
+ msg = json.loads(first.decode("utf-8", "replace"))
+ except Exception:
+ msg = None
+ if isinstance(msg, dict):
+ self._mode = "line"
+ return msg
+
+ headers: Dict[str, str] = {}
+ try:
+ text = first.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ while True:
+ line = self._in.readline()
+ if not line:
+ return None
+ if line in (b"\r\n", b"\n"):
+ break
+ try:
+ text = line.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ if "content-length" not in headers:
+ return None
+ try:
+ length = int(headers["content-length"])
+ except ValueError:
+ return None
+ body = self._in.read(length)
+ if not body:
+ return None
+ self._mode = "headers"
+ msg = json.loads(body.decode("utf-8", "replace"))
+ if isinstance(msg, dict):
+ return msg
+ return None
+
+ def write_message(self, message: Dict[str, Any]) -> None:
+ if self._mode == "line":
+ payload = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ self._out.write(payload + b"\n")
+ self._out.flush()
+ return
+
+ body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode(
+ "utf-8"
+ )
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
+ self._out.write(header)
+ self._out.write(body)
+ self._out.flush()
+
+
+def main() -> None:
+ tools = AkashDocsTools()
+ rpc = StdioJsonRpc()
+
+ handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
+ "akash_docs_list_routes": lambda a: tools.akash_docs_list_routes(),
+ "akash_docs_fetch": lambda a: tools.akash_docs_fetch(**a),
+ "akash_docs_search": lambda a: tools.akash_docs_search(**a),
+ "akash_sdl_snippet": lambda a: tools.akash_sdl_snippet(**a),
+ }
+
+ while True:
+ msg = rpc.read_message()
+ if msg is None:
+ return
+
+ method = msg.get("method")
+ msg_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ result = {
+ "protocolVersion": "2024-11-05",
+ "serverInfo": {"name": "akash_docs", "version": "0.1.0"},
+ "capabilities": {"tools": {}},
+ }
+ rpc.write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ continue
+
+ if method == "tools/list":
+ rpc.write_message(
+ {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}}
+ )
+ continue
+
+ if method == "tools/call":
+ tool_name = str(params.get("name") or "")
+ args = params.get("arguments") or {}
+ if tool_name not in handlers:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ f"Unknown tool: {tool_name}\nKnown tools: {', '.join(sorted(handlers.keys()))}",
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ try:
+ payload = handlers[tool_name](args)
+ # Split payload: meta JSON + optional raw content.
+ # If payload["data"]["content"] exists, emit it as a second text block for readability.
+ data = payload.get("data") if isinstance(payload, dict) else None
+ content_text = None
+ if isinstance(data, dict) and isinstance(data.get("content"), str):
+ content_text = data["content"]
+ data = dict(data)
+ data.pop("content", None)
+ payload = dict(payload)
+ payload["data"] = data
+
+ blocks = [json.dumps(payload, ensure_ascii=False, indent=2)]
+ if content_text:
+ blocks.append(content_text)
+ result: Dict[str, Any] = {
+ "content": [{"type": "text", "text": b} for b in blocks]
+ }
+ rpc.write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ except Exception as e: # noqa: BLE001
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ f"Error: {e}",
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ # Ignore notifications.
+ if msg_id is None:
+ continue
+
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ f"Unsupported method: {method}",
+ is_error=True,
+ ),
+ }
+ )
+ except Exception as e: # noqa: BLE001
+ # Last-resort: avoid crashing the server.
+ if msg_id is not None:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(f"fatal error: {e}", is_error=True),
+ }
+ )
diff --git a/mcp/cloudflare_safe/__init__.py b/mcp/cloudflare_safe/__init__.py
new file mode 100644
index 0000000..b3f679e
--- /dev/null
+++ b/mcp/cloudflare_safe/__init__.py
@@ -0,0 +1,11 @@
+"""
+cloudflare_safe MCP server.
+
+Summary-first Cloudflare tooling with hard output caps and default redaction.
+"""
+
+from __future__ import annotations
+
+__all__ = ["__version__"]
+
+__version__ = "0.1.0"
diff --git a/mcp/cloudflare_safe/__main__.py b/mcp/cloudflare_safe/__main__.py
new file mode 100644
index 0000000..d998836
--- /dev/null
+++ b/mcp/cloudflare_safe/__main__.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+from .server import main
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp/cloudflare_safe/cloudflare_api.py b/mcp/cloudflare_safe/cloudflare_api.py
new file mode 100644
index 0000000..c6182ad
--- /dev/null
+++ b/mcp/cloudflare_safe/cloudflare_api.py
@@ -0,0 +1,496 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import (
+ Any,
+ Dict,
+ Iterable,
+ List,
+ Mapping,
+ MutableMapping,
+ Optional,
+ Sequence,
+ Tuple,
+)
+
+CF_API_BASE = "https://api.cloudflare.com/client/v4"
+
+
+def utc_now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def stable_hash(data: Any) -> str:
+ blob = json.dumps(
+ data, sort_keys=True, separators=(",", ":"), ensure_ascii=False
+ ).encode("utf-8")
+ return hashlib.sha256(blob).hexdigest()
+
+
+class CloudflareError(RuntimeError):
+ pass
+
+
+@dataclass(frozen=True)
+class CloudflareContext:
+ api_token: str
+ account_id: str
+
+ @staticmethod
+ def from_env() -> "CloudflareContext":
+ api_token = (
+ os.getenv("CLOUDFLARE_API_TOKEN")
+ or os.getenv("CF_API_TOKEN")
+ or os.getenv("CLOUDFLARE_TOKEN")
+ or ""
+ ).strip()
+ account_id = (
+ os.getenv("CLOUDFLARE_ACCOUNT_ID") or os.getenv("CF_ACCOUNT_ID") or ""
+ ).strip()
+
+ if not api_token:
+ raise CloudflareError(
+ "Missing Cloudflare API token. Set CLOUDFLARE_API_TOKEN (or CF_API_TOKEN)."
+ )
+ if not account_id:
+ raise CloudflareError(
+ "Missing Cloudflare account id. Set CLOUDFLARE_ACCOUNT_ID (or CF_ACCOUNT_ID)."
+ )
+ return CloudflareContext(api_token=api_token, account_id=account_id)
+
+
+class CloudflareClient:
+ def __init__(self, *, api_token: str) -> None:
+ self.api_token = api_token
+
+ def _request(
+ self,
+ method: str,
+ path: str,
+ *,
+ params: Optional[Mapping[str, str]] = None,
+ ) -> Dict[str, Any]:
+ url = f"{CF_API_BASE}{path}"
+ if params:
+ url = f"{url}?{urllib.parse.urlencode(params)}"
+
+ req = urllib.request.Request(
+ url=url,
+ method=method,
+ headers={
+ "Authorization": f"Bearer {self.api_token}",
+ "Accept": "application/json",
+ "Content-Type": "application/json",
+ },
+ )
+
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ raw = resp.read()
+ except urllib.error.HTTPError as e:
+ raw = e.read() if hasattr(e, "read") else b""
+ detail = raw.decode("utf-8", "replace")
+ raise CloudflareError(
+ f"Cloudflare API HTTP {e.code} for {path}: {detail}"
+ ) from e
+ except urllib.error.URLError as e:
+ raise CloudflareError(
+ f"Cloudflare API request failed for {path}: {e}"
+ ) from e
+
+ try:
+ data = json.loads(raw.decode("utf-8", "replace"))
+ except json.JSONDecodeError:
+ raise CloudflareError(
+ f"Cloudflare API returned non-JSON for {path}: {raw[:200]!r}"
+ )
+
+ if not data.get("success", True):
+ raise CloudflareError(
+ f"Cloudflare API error for {path}: {data.get('errors')}"
+ )
+
+ return data
+
+ def paginate(
+ self,
+ path: str,
+ *,
+ params: Optional[Mapping[str, str]] = None,
+ per_page: int = 100,
+ max_pages: int = 5,
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ """
+ Fetch a paginated Cloudflare endpoint.
+
+ Returns (results, result_info).
+ """
+ results: List[Dict[str, Any]] = []
+ page = 1
+ last_info: Dict[str, Any] = {}
+
+ while True:
+ merged_params: Dict[str, str] = {
+ "page": str(page),
+ "per_page": str(per_page),
+ }
+ if params:
+ merged_params.update({k: str(v) for k, v in params.items()})
+
+ data = self._request("GET", path, params=merged_params)
+ batch = data.get("result") or []
+ if not isinstance(batch, list):
+ batch = [batch]
+ results.extend(batch)
+ last_info = data.get("result_info") or {}
+
+ total_pages = int(last_info.get("total_pages") or 1)
+ if page >= total_pages or page >= max_pages:
+ break
+ page += 1
+
+ return results, last_info
+
+ def list_zones(self) -> List[Dict[str, Any]]:
+ zones, _info = self.paginate("/zones", max_pages=2)
+ return zones
+
+ def list_dns_records_summary(
+ self, zone_id: str, *, max_pages: int = 1
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
+ return self.paginate(f"/zones/{zone_id}/dns_records", max_pages=max_pages)
+
+ def list_tunnels(self, account_id: str) -> List[Dict[str, Any]]:
+ tunnels, _info = self.paginate(
+ f"/accounts/{account_id}/cfd_tunnel", max_pages=2
+ )
+ return tunnels
+
+ def list_tunnel_connections(
+ self, account_id: str, tunnel_id: str
+ ) -> List[Dict[str, Any]]:
+ data = self._request(
+ "GET", f"/accounts/{account_id}/cfd_tunnel/{tunnel_id}/connections"
+ )
+ result = data.get("result") or []
+ return result if isinstance(result, list) else [result]
+
+ def list_access_apps(self, account_id: str) -> List[Dict[str, Any]]:
+ apps, _info = self.paginate(f"/accounts/{account_id}/access/apps", max_pages=3)
+ return apps
+
+ def list_access_policies(
+ self, account_id: str, app_id: str
+ ) -> List[Dict[str, Any]]:
+ policies, _info = self.paginate(
+ f"/accounts/{account_id}/access/apps/{app_id}/policies",
+ max_pages=3,
+ )
+ return policies
+
+
+@dataclass(frozen=True)
+class SnapshotMeta:
+ snapshot_id: str
+ created_at: str
+ scopes: List[str]
+ snapshot_path: str
+
+
+class SnapshotStore:
+ def __init__(self, root_dir: Path) -> None:
+ self.root_dir = root_dir
+ self.snapshots_dir = root_dir / "snapshots"
+ self.diffs_dir = root_dir / "diffs"
+ self.snapshots_dir.mkdir(parents=True, exist_ok=True)
+ self.diffs_dir.mkdir(parents=True, exist_ok=True)
+ self._index: Dict[str, SnapshotMeta] = {}
+
+ def get(self, snapshot_id: str) -> SnapshotMeta:
+ if snapshot_id not in self._index:
+ raise CloudflareError(f"Unknown snapshot_id: {snapshot_id}")
+ return self._index[snapshot_id]
+
+ def load_snapshot(self, snapshot_id: str) -> Dict[str, Any]:
+ meta = self.get(snapshot_id)
+ return json.loads(Path(meta.snapshot_path).read_text(encoding="utf-8"))
+
+ def create_snapshot(
+ self,
+ *,
+ client: CloudflareClient,
+ ctx: CloudflareContext,
+ scopes: Sequence[str],
+ zone_id: Optional[str] = None,
+ zone_name: Optional[str] = None,
+ dns_max_pages: int = 1,
+ ) -> Tuple[SnapshotMeta, Dict[str, Any]]:
+ scopes_norm = sorted(set(scopes))
+ created_at = utc_now_iso()
+
+ zones = client.list_zones()
+ zones_min = [
+ {
+ "id": z.get("id"),
+ "name": z.get("name"),
+ "status": z.get("status"),
+ "paused": z.get("paused"),
+ }
+ for z in zones
+ ]
+
+ selected_zone_id = zone_id
+ if not selected_zone_id and zone_name:
+ for z in zones_min:
+ if z.get("name") == zone_name:
+ selected_zone_id = str(z.get("id"))
+ break
+
+ snapshot: Dict[str, Any] = {
+ "meta": {
+ "snapshot_id": "",
+ "created_at": created_at,
+ "account_id": ctx.account_id,
+ "scopes": scopes_norm,
+ },
+ "zones": zones_min,
+ }
+
+ if "tunnels" in scopes_norm:
+ tunnels = client.list_tunnels(ctx.account_id)
+ tunnels_min: List[Dict[str, Any]] = []
+ for t in tunnels:
+ tid = t.get("id")
+ name = t.get("name")
+ status = t.get("status")
+ connector_count: Optional[int] = None
+ last_seen: Optional[str] = None
+ if tid and status != "deleted":
+ conns = client.list_tunnel_connections(ctx.account_id, str(tid))
+ connector_count = len(conns)
+ # Pick the most recent 'opened_at' if present.
+ opened = [c.get("opened_at") for c in conns if isinstance(c, dict)]
+ opened = [o for o in opened if isinstance(o, str)]
+ last_seen = max(opened) if opened else None
+
+ tunnels_min.append(
+ {
+ "id": tid,
+ "name": name,
+ "status": status,
+ "created_at": t.get("created_at"),
+ "deleted_at": t.get("deleted_at"),
+ "connector_count": connector_count,
+ "last_seen": last_seen,
+ }
+ )
+ snapshot["tunnels"] = tunnels_min
+
+ if "access_apps" in scopes_norm:
+ apps = client.list_access_apps(ctx.account_id)
+ apps_min = [
+ {
+ "id": a.get("id"),
+ "name": a.get("name"),
+ "domain": a.get("domain"),
+ "type": a.get("type"),
+ "created_at": a.get("created_at"),
+ "updated_at": a.get("updated_at"),
+ }
+ for a in apps
+ ]
+ snapshot["access_apps"] = apps_min
+
+ if "dns" in scopes_norm:
+ if selected_zone_id:
+ records, info = client.list_dns_records_summary(
+ selected_zone_id, max_pages=dns_max_pages
+ )
+ records_min = [
+ {
+ "id": r.get("id"),
+ "type": r.get("type"),
+ "name": r.get("name"),
+ "content": r.get("content"),
+ "proxied": r.get("proxied"),
+ "ttl": r.get("ttl"),
+ }
+ for r in records
+ ]
+ snapshot["dns"] = {
+ "zone_id": selected_zone_id,
+ "zone_name": zone_name,
+ "result_info": info,
+ "records_sample": records_min,
+ }
+ else:
+ snapshot["dns"] = {
+ "note": "dns scope requested but no zone_id/zone_name provided; only zones list included",
+ }
+
+ snapshot_id = f"cf_{created_at.replace(':', '').replace('-', '').replace('.', '')}_{stable_hash(snapshot)[:10]}"
+ snapshot["meta"]["snapshot_id"] = snapshot_id
+
+ path = self.snapshots_dir / f"{snapshot_id}.json"
+ path.write_text(
+ json.dumps(snapshot, indent=2, ensure_ascii=False), encoding="utf-8"
+ )
+
+ meta = SnapshotMeta(
+ snapshot_id=snapshot_id,
+ created_at=created_at,
+ scopes=scopes_norm,
+ snapshot_path=str(path),
+ )
+ self._index[snapshot_id] = meta
+ return meta, snapshot
+
+ def diff(
+ self,
+ *,
+ from_snapshot_id: str,
+ to_snapshot_id: str,
+ scopes: Optional[Sequence[str]] = None,
+ ) -> Dict[str, Any]:
+ before = self.load_snapshot(from_snapshot_id)
+ after = self.load_snapshot(to_snapshot_id)
+
+ scopes_before = set(before.get("meta", {}).get("scopes") or [])
+ scopes_after = set(after.get("meta", {}).get("scopes") or [])
+ scopes_all = sorted(scopes_before | scopes_after)
+ scopes_use = sorted(set(scopes or scopes_all))
+
+ def index_by_id(
+ items: Iterable[Mapping[str, Any]],
+ ) -> Dict[str, Dict[str, Any]]:
+ out: Dict[str, Dict[str, Any]] = {}
+ for it in items:
+ _id = it.get("id")
+ if _id is None:
+ continue
+ out[str(_id)] = dict(it)
+ return out
+
+ diff_out: Dict[str, Any] = {
+ "from": from_snapshot_id,
+ "to": to_snapshot_id,
+ "scopes": scopes_use,
+ "changes": {},
+ }
+
+ for scope in scopes_use:
+ if scope not in {"tunnels", "access_apps", "zones"}:
+ continue
+ b_items = before.get(scope) or []
+ a_items = after.get(scope) or []
+ if not isinstance(b_items, list) or not isinstance(a_items, list):
+ continue
+ b_map = index_by_id(b_items)
+ a_map = index_by_id(a_items)
+ added = [a_map[k] for k in sorted(set(a_map) - set(b_map))]
+ removed = [b_map[k] for k in sorted(set(b_map) - set(a_map))]
+
+ changed: List[Dict[str, Any]] = []
+ for k in sorted(set(a_map) & set(b_map)):
+ if stable_hash(a_map[k]) != stable_hash(b_map[k]):
+ changed.append({"id": k, "before": b_map[k], "after": a_map[k]})
+
+ diff_out["changes"][scope] = {
+ "added": [{"id": x.get("id"), "name": x.get("name")} for x in added],
+ "removed": [
+ {"id": x.get("id"), "name": x.get("name")} for x in removed
+ ],
+ "changed": [
+ {"id": x.get("id"), "name": x.get("after", {}).get("name")}
+ for x in changed
+ ],
+ "counts": {
+ "added": len(added),
+ "removed": len(removed),
+ "changed": len(changed),
+ },
+ }
+
+ diff_path = self.diffs_dir / f"{from_snapshot_id}_to_{to_snapshot_id}.json"
+ diff_path.write_text(
+ json.dumps(diff_out, indent=2, ensure_ascii=False),
+ encoding="utf-8",
+ )
+ diff_out["diff_path"] = str(diff_path)
+ return diff_out
+
+
+def parse_cloudflared_config_ingress(config_text: str) -> List[Dict[str, str]]:
+ """
+ Best-effort parser for cloudflared YAML config ingress rules.
+
+ We intentionally avoid a YAML dependency; this extracts common patterns:
+ - hostname: example.com
+ service: http://127.0.0.1:8080
+ """
+ rules: List[Dict[str, str]] = []
+ lines = config_text.splitlines()
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ stripped = line.lstrip()
+ if not stripped.startswith("-"):
+ i += 1
+ continue
+ after_dash = stripped[1:].lstrip()
+ if not after_dash.startswith("hostname:"):
+ i += 1
+ continue
+
+ hostname = after_dash[len("hostname:") :].strip().strip('"').strip("'")
+ base_indent = len(line) - len(line.lstrip())
+
+ service = ""
+ j = i + 1
+ while j < len(lines):
+ next_line = lines[j]
+ if next_line.strip() == "":
+ j += 1
+ continue
+
+ next_indent = len(next_line) - len(next_line.lstrip())
+ if next_indent <= base_indent:
+ break
+
+ next_stripped = next_line.lstrip()
+ if next_stripped.startswith("service:"):
+ service = next_stripped[len("service:") :].strip().strip('"').strip("'")
+ break
+ j += 1
+
+ rules.append({"hostname": hostname, "service": service})
+ i = j
+ return rules
+
+
+def ingress_summary_from_file(
+ *,
+ config_path: str,
+ max_rules: int = 50,
+) -> Dict[str, Any]:
+ path = Path(config_path)
+ if not path.exists():
+ raise CloudflareError(f"cloudflared config not found: {config_path}")
+ text = path.read_text(encoding="utf-8", errors="replace")
+ rules = parse_cloudflared_config_ingress(text)
+ hostnames = sorted({r["hostname"] for r in rules if r.get("hostname")})
+ return {
+ "config_path": config_path,
+ "ingress_rule_count": len(rules),
+ "hostnames": hostnames[:max_rules],
+ "rules_sample": rules[:max_rules],
+ "truncated": len(rules) > max_rules,
+ }
diff --git a/mcp/cloudflare_safe/server.py b/mcp/cloudflare_safe/server.py
new file mode 100644
index 0000000..5a23f1d
--- /dev/null
+++ b/mcp/cloudflare_safe/server.py
@@ -0,0 +1,725 @@
+from __future__ import annotations
+
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
+
+from .cloudflare_api import (
+ CloudflareClient,
+ CloudflareContext,
+ CloudflareError,
+ SnapshotStore,
+ ingress_summary_from_file,
+)
+
+MAX_BYTES_DEFAULT = 32_000
+
+
+def _repo_root() -> Path:
+ # server.py -> cloudflare_safe -> mcp ->
+ return Path(__file__).resolve().parents[3]
+
+
+def _max_bytes() -> int:
+ raw = (os.getenv("VM_MCP_MAX_BYTES") or "").strip()
+ if not raw:
+ return MAX_BYTES_DEFAULT
+ try:
+ return max(4_096, int(raw))
+ except ValueError:
+ return MAX_BYTES_DEFAULT
+
+
+def _redact(obj: Any) -> Any:
+ sensitive_keys = ("token", "secret", "password", "private", "key", "certificate")
+
+ if isinstance(obj, dict):
+ out: Dict[str, Any] = {}
+ for k, v in obj.items():
+ if any(s in str(k).lower() for s in sensitive_keys):
+ out[k] = ""
+ else:
+ out[k] = _redact(v)
+ return out
+ if isinstance(obj, list):
+ return [_redact(v) for v in obj]
+ if isinstance(obj, str):
+ if obj.startswith("ghp_") or obj.startswith("github_pat_"):
+ return ""
+ return obj
+ return obj
+
+
+def _safe_json(payload: Dict[str, Any]) -> str:
+ payload = _redact(payload)
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
+ if len(raw.encode("utf-8")) <= _max_bytes():
+ return json.dumps(payload, ensure_ascii=False, indent=2)
+
+ # Truncate: keep only summary + next_steps.
+ truncated = {
+ "ok": payload.get("ok", True),
+ "truncated": True,
+ "summary": payload.get("summary", "Response exceeded max size; truncated."),
+ "next_steps": payload.get(
+ "next_steps",
+ [
+ "request a narrower scope (e.g., scopes=['tunnels'])",
+ "request an export path instead of inline content",
+ ],
+ ),
+ }
+ return json.dumps(truncated, ensure_ascii=False, indent=2)
+
+
+def _mcp_text_result(
+ payload: Dict[str, Any], *, is_error: bool = False
+) -> Dict[str, Any]:
+ result: Dict[str, Any] = {
+ "content": [{"type": "text", "text": _safe_json(payload)}]
+ }
+ if is_error:
+ result["isError"] = True
+ return result
+
+
+def _default_state_dir() -> Path:
+ return _repo_root() / "archive_runtime" / "cloudflare_mcp"
+
+
+class CloudflareSafeTools:
+ def __init__(self) -> None:
+ self.store = SnapshotStore(
+ Path(os.getenv("VM_CF_MCP_STATE_DIR") or _default_state_dir())
+ )
+
+ def cf_snapshot(
+ self,
+ *,
+ scopes: Optional[Sequence[str]] = None,
+ zone_id: Optional[str] = None,
+ zone_name: Optional[str] = None,
+ dns_max_pages: int = 1,
+ ) -> Dict[str, Any]:
+ scopes_use = list(scopes or ["tunnels", "access_apps"])
+ ctx = CloudflareContext.from_env()
+ client = CloudflareClient(api_token=ctx.api_token)
+ meta, snapshot = self.store.create_snapshot(
+ client=client,
+ ctx=ctx,
+ scopes=scopes_use,
+ zone_id=zone_id,
+ zone_name=zone_name,
+ dns_max_pages=dns_max_pages,
+ )
+
+ summary = (
+ f"Snapshot {meta.snapshot_id} captured "
+ f"(scopes={','.join(meta.scopes)}) and written to {meta.snapshot_path}."
+ )
+ return {
+ "ok": True,
+ "summary": summary,
+ "data": {
+ "snapshot_id": meta.snapshot_id,
+ "created_at": meta.created_at,
+ "scopes": meta.scopes,
+ "snapshot_path": meta.snapshot_path,
+ "counts": {
+ "zones": len(snapshot.get("zones") or []),
+ "tunnels": len(snapshot.get("tunnels") or []),
+ "access_apps": len(snapshot.get("access_apps") or []),
+ },
+ },
+ "truncated": False,
+ "next_steps": [
+ "cf_config_diff(from_snapshot_id=..., to_snapshot_id=...)",
+ "cf_export_config(full=false, snapshot_id=...)",
+ ],
+ }
+
+ def cf_refresh(
+ self,
+ *,
+ snapshot_id: str,
+ scopes: Optional[Sequence[str]] = None,
+ dns_max_pages: int = 1,
+ ) -> Dict[str, Any]:
+ before_meta = self.store.get(snapshot_id)
+ before = self.store.load_snapshot(snapshot_id)
+ scopes_use = list(scopes or (before.get("meta", {}).get("scopes") or []))
+
+ ctx = CloudflareContext.from_env()
+ client = CloudflareClient(api_token=ctx.api_token)
+
+ meta, _snapshot = self.store.create_snapshot(
+ client=client,
+ ctx=ctx,
+ scopes=scopes_use,
+ zone_id=(before.get("dns") or {}).get("zone_id"),
+ zone_name=(before.get("dns") or {}).get("zone_name"),
+ dns_max_pages=dns_max_pages,
+ )
+
+ return {
+ "ok": True,
+ "summary": f"Refreshed {before_meta.snapshot_id} -> {meta.snapshot_id} (scopes={','.join(meta.scopes)}).",
+ "data": {
+ "from_snapshot_id": before_meta.snapshot_id,
+ "to_snapshot_id": meta.snapshot_id,
+ "snapshot_path": meta.snapshot_path,
+ },
+ "truncated": False,
+ "next_steps": [
+ "cf_config_diff(from_snapshot_id=..., to_snapshot_id=...)",
+ ],
+ }
+
+ def cf_config_diff(
+ self,
+ *,
+ from_snapshot_id: str,
+ to_snapshot_id: str,
+ scopes: Optional[Sequence[str]] = None,
+ ) -> Dict[str, Any]:
+ diff = self.store.diff(
+ from_snapshot_id=from_snapshot_id,
+ to_snapshot_id=to_snapshot_id,
+ scopes=scopes,
+ )
+
+ # Keep the response small; point to diff_path for full detail.
+ changes = diff.get("changes") or {}
+ counts = {
+ scope: (changes.get(scope) or {}).get("counts")
+ for scope in sorted(changes.keys())
+ }
+ return {
+ "ok": True,
+ "summary": f"Diff computed and written to {diff.get('diff_path')}.",
+ "data": {
+ "from_snapshot_id": from_snapshot_id,
+ "to_snapshot_id": to_snapshot_id,
+ "scopes": diff.get("scopes"),
+ "counts": counts,
+ "diff_path": diff.get("diff_path"),
+ },
+ "truncated": False,
+ "next_steps": [
+ "Use filesystem MCP to open diff_path for full details",
+ "Run cf_export_config(full=false, snapshot_id=...) for a safe export path",
+ ],
+ }
+
+ def cf_export_config(
+ self,
+ *,
+ snapshot_id: Optional[str] = None,
+ full: bool = False,
+ scopes: Optional[Sequence[str]] = None,
+ ) -> Dict[str, Any]:
+ if snapshot_id is None:
+ snap = self.cf_snapshot(scopes=scopes)
+ snapshot_id = str((snap.get("data") or {}).get("snapshot_id"))
+
+ meta = self.store.get(snapshot_id)
+ if not full:
+ return {
+ "ok": True,
+ "summary": "Export is summary-first; full config requires full=true.",
+ "data": {
+ "snapshot_id": meta.snapshot_id,
+ "snapshot_path": meta.snapshot_path,
+ },
+ "truncated": False,
+ "next_steps": [
+ "Use filesystem MCP to open snapshot_path",
+ "If you truly need inline data, call cf_export_config(full=true, snapshot_id=...)",
+ ],
+ }
+
+ snapshot = self.store.load_snapshot(snapshot_id)
+ return {
+ "ok": True,
+ "summary": "Full snapshot export (redacted + size-capped). Prefer snapshot_path for large data.",
+ "data": snapshot,
+ "truncated": False,
+ "next_steps": [
+ f"Snapshot file: {meta.snapshot_path}",
+ ],
+ }
+
+ def cf_tunnel_status(
+ self,
+ *,
+ snapshot_id: Optional[str] = None,
+ tunnel_name: Optional[str] = None,
+ tunnel_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ if snapshot_id:
+ snap = self.store.load_snapshot(snapshot_id)
+ tunnels = snap.get("tunnels") or []
+ else:
+ snap = self.cf_snapshot(scopes=["tunnels"])
+ sid = str((snap.get("data") or {}).get("snapshot_id"))
+ tunnels = self.store.load_snapshot(sid).get("tunnels") or []
+
+ def matches(t: Dict[str, Any]) -> bool:
+ if tunnel_id and str(t.get("id")) != str(tunnel_id):
+ return False
+ if tunnel_name and str(t.get("name")) != str(tunnel_name):
+ return False
+ return True
+
+ filtered = [t for t in tunnels if isinstance(t, dict) and matches(t)]
+ if not filtered and (tunnel_id or tunnel_name):
+ return {
+ "ok": False,
+ "summary": "Tunnel not found in snapshot.",
+ "data": {"tunnel_id": tunnel_id, "tunnel_name": tunnel_name},
+ "truncated": False,
+ "next_steps": ["Call cf_snapshot(scopes=['tunnels']) and retry."],
+ }
+
+ connectors = [t.get("connector_count") for t in filtered if isinstance(t, dict)]
+ connectors = [c for c in connectors if isinstance(c, int)]
+ return {
+ "ok": True,
+ "summary": f"Returned {len(filtered)} tunnel(s).",
+ "data": {
+ "tunnels": [
+ {
+ "id": t.get("id"),
+ "name": t.get("name"),
+ "status": t.get("status"),
+ "connector_count": t.get("connector_count"),
+ "last_seen": t.get("last_seen"),
+ }
+ for t in filtered
+ ],
+ "connectors_total": sum(connectors) if connectors else 0,
+ },
+ "truncated": False,
+ "next_steps": [
+ "For local ingress hostnames, use cf_tunnel_ingress_summary(config_path='/etc/cloudflared/config.yml')",
+ ],
+ }
+
+ def cf_tunnel_ingress_summary(
+ self,
+ *,
+ config_path: str = "/etc/cloudflared/config.yml",
+ full: bool = False,
+ max_rules: int = 50,
+ ) -> Dict[str, Any]:
+ summary = ingress_summary_from_file(
+ config_path=config_path, max_rules=max_rules
+ )
+ if not full:
+ return {
+ "ok": True,
+ "summary": f"Parsed ingress hostnames from {config_path}.",
+ "data": {
+ "config_path": summary["config_path"],
+ "ingress_rule_count": summary["ingress_rule_count"],
+ "hostnames": summary["hostnames"],
+ "truncated": summary["truncated"],
+ },
+ "truncated": False,
+ "next_steps": [
+ "Call cf_tunnel_ingress_summary(full=true, ...) to include service mappings (still capped).",
+ ],
+ }
+ return {
+ "ok": True,
+ "summary": f"Ingress summary (full=true) for {config_path}.",
+ "data": summary,
+ "truncated": False,
+ "next_steps": [],
+ }
+
+ def cf_access_policy_list(
+ self,
+ *,
+ app_id: Optional[str] = None,
+ ) -> Dict[str, Any]:
+ ctx = CloudflareContext.from_env()
+ client = CloudflareClient(api_token=ctx.api_token)
+
+ if not app_id:
+ apps = client.list_access_apps(ctx.account_id)
+ apps_min = [
+ {
+ "id": a.get("id"),
+ "name": a.get("name"),
+ "domain": a.get("domain"),
+ "type": a.get("type"),
+ }
+ for a in apps
+ ]
+ return {
+ "ok": True,
+ "summary": f"Returned {len(apps_min)} Access app(s). Provide app_id to list policies.",
+ "data": {"apps": apps_min},
+ "truncated": False,
+ "next_steps": [
+ "Call cf_access_policy_list(app_id=...)",
+ ],
+ }
+
+ policies = client.list_access_policies(ctx.account_id, app_id)
+ policies_min = [
+ {
+ "id": p.get("id"),
+ "name": p.get("name"),
+ "decision": p.get("decision"),
+ "precedence": p.get("precedence"),
+ }
+ for p in policies
+ ]
+ return {
+ "ok": True,
+ "summary": f"Returned {len(policies_min)} policy/policies for app_id={app_id}.",
+ "data": {"app_id": app_id, "policies": policies_min},
+ "truncated": False,
+ "next_steps": [],
+ }
+
+
+TOOLS: List[Dict[str, Any]] = [
+ {
+ "name": "cf_snapshot",
+ "description": "Create a summary-first Cloudflare state snapshot (writes JSON to disk; returns snapshot_id + paths).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "scopes": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Scopes to fetch (default: ['tunnels','access_apps']). Supported: zones,tunnels,access_apps,dns",
+ },
+ "zone_id": {"type": "string"},
+ "zone_name": {"type": "string"},
+ "dns_max_pages": {"type": "integer", "default": 1},
+ },
+ },
+ },
+ {
+ "name": "cf_refresh",
+ "description": "Refresh a prior snapshot (creates a new snapshot_id).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "snapshot_id": {"type": "string"},
+ "scopes": {"type": "array", "items": {"type": "string"}},
+ "dns_max_pages": {"type": "integer", "default": 1},
+ },
+ "required": ["snapshot_id"],
+ },
+ },
+ {
+ "name": "cf_config_diff",
+ "description": "Diff two snapshots (summary counts inline; full diff written to disk).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "from_snapshot_id": {"type": "string"},
+ "to_snapshot_id": {"type": "string"},
+ "scopes": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["from_snapshot_id", "to_snapshot_id"],
+ },
+ },
+ {
+ "name": "cf_export_config",
+ "description": "Export snapshot config. Defaults to summary-only; full=true returns redacted + size-capped data.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "snapshot_id": {"type": "string"},
+ "full": {"type": "boolean", "default": False},
+ "scopes": {"type": "array", "items": {"type": "string"}},
+ },
+ },
+ },
+ {
+ "name": "cf_tunnel_status",
+ "description": "Return tunnel status summary (connector count, last seen).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "snapshot_id": {"type": "string"},
+ "tunnel_name": {"type": "string"},
+ "tunnel_id": {"type": "string"},
+ },
+ },
+ },
+ {
+ "name": "cf_tunnel_ingress_summary",
+ "description": "Parse cloudflared ingress hostnames from a local config file (never dumps full YAML unless full=true, still capped).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "config_path": {
+ "type": "string",
+ "default": "/etc/cloudflared/config.yml",
+ },
+ "full": {"type": "boolean", "default": False},
+ "max_rules": {"type": "integer", "default": 50},
+ },
+ },
+ },
+ {
+ "name": "cf_access_policy_list",
+ "description": "List Access apps, or policies for a specific app_id (summary-only).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "app_id": {"type": "string"},
+ },
+ },
+ },
+]
+
+
+class StdioJsonRpc:
+ def __init__(self) -> None:
+ self._in = sys.stdin.buffer
+ self._out = sys.stdout.buffer
+ self._mode: str | None = None # "headers" | "line"
+
+ def read_message(self) -> Optional[Dict[str, Any]]:
+ while True:
+ if self._mode == "line":
+ line = self._in.readline()
+ if not line:
+ return None
+ raw = line.decode("utf-8", "replace").strip()
+ if not raw:
+ continue
+ try:
+ msg = json.loads(raw)
+ except Exception:
+ continue
+ if isinstance(msg, dict):
+ return msg
+ continue
+
+ first = self._in.readline()
+ if not first:
+ return None
+
+ if first in (b"\r\n", b"\n"):
+ continue
+
+ # Auto-detect newline-delimited JSON framing.
+ if self._mode is None and first.lstrip().startswith(b"{"):
+ try:
+ msg = json.loads(first.decode("utf-8", "replace"))
+ except Exception:
+ msg = None
+ if isinstance(msg, dict):
+ self._mode = "line"
+ return msg
+
+ headers: Dict[str, str] = {}
+ try:
+ text = first.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ while True:
+ line = self._in.readline()
+ if not line:
+ return None
+ if line in (b"\r\n", b"\n"):
+ break
+ try:
+ text = line.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ if "content-length" not in headers:
+ return None
+ try:
+ length = int(headers["content-length"])
+ except ValueError:
+ return None
+ body = self._in.read(length)
+ if not body:
+ return None
+ self._mode = "headers"
+ msg = json.loads(body.decode("utf-8", "replace"))
+ if isinstance(msg, dict):
+ return msg
+ return None
+
+ def write_message(self, message: Dict[str, Any]) -> None:
+ if self._mode == "line":
+ payload = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ self._out.write(payload + b"\n")
+ self._out.flush()
+ return
+
+ body = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode(
+ "utf-8"
+ )
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
+ self._out.write(header)
+ self._out.write(body)
+ self._out.flush()
+
+
+def main() -> None:
+ tools = CloudflareSafeTools()
+ rpc = StdioJsonRpc()
+
+ handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
+ "cf_snapshot": lambda a: tools.cf_snapshot(**a),
+ "cf_refresh": lambda a: tools.cf_refresh(**a),
+ "cf_config_diff": lambda a: tools.cf_config_diff(**a),
+ "cf_export_config": lambda a: tools.cf_export_config(**a),
+ "cf_tunnel_status": lambda a: tools.cf_tunnel_status(**a),
+ "cf_tunnel_ingress_summary": lambda a: tools.cf_tunnel_ingress_summary(**a),
+ "cf_access_policy_list": lambda a: tools.cf_access_policy_list(**a),
+ }
+
+ while True:
+ msg = rpc.read_message()
+ if msg is None:
+ return
+
+ method = msg.get("method")
+ msg_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ result = {
+ "protocolVersion": "2024-11-05",
+ "serverInfo": {"name": "cloudflare_safe", "version": "0.1.0"},
+ "capabilities": {"tools": {}},
+ }
+ rpc.write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ continue
+
+ if method == "tools/list":
+ rpc.write_message(
+ {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}}
+ )
+ continue
+
+ if method == "tools/call":
+ tool_name = str(params.get("name") or "")
+ args = params.get("arguments") or {}
+ if tool_name not in handlers:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"Unknown tool: {tool_name}",
+ "data": {"known_tools": sorted(handlers.keys())},
+ "truncated": False,
+ "next_steps": ["Call tools/list"],
+ },
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ try:
+ payload = handlers[tool_name](args)
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(payload),
+ }
+ )
+ except CloudflareError as e:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": str(e),
+ "truncated": False,
+ "next_steps": [
+ "Verify CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are set",
+ "Retry with a narrower scope",
+ ],
+ },
+ is_error=True,
+ ),
+ }
+ )
+ except Exception as e: # noqa: BLE001
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"Unhandled error: {e}",
+ "truncated": False,
+ "next_steps": ["Retry with a narrower scope"],
+ },
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ # Ignore notifications.
+ if msg_id is None:
+ continue
+
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"Unsupported method: {method}",
+ "truncated": False,
+ },
+ is_error=True,
+ ),
+ }
+ )
+ except Exception as e: # noqa: BLE001
+ # Last-resort: avoid crashing the server.
+ if msg_id is not None:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"fatal error: {e}",
+ "truncated": False,
+ },
+ ),
+ }
+ )
diff --git a/mcp/oracle_answer/__main__.py b/mcp/oracle_answer/__main__.py
new file mode 100644
index 0000000..d998836
--- /dev/null
+++ b/mcp/oracle_answer/__main__.py
@@ -0,0 +1,6 @@
+from __future__ import annotations
+
+from .server import main
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp/oracle_answer/server.py b/mcp/oracle_answer/server.py
new file mode 100644
index 0000000..5a3aff6
--- /dev/null
+++ b/mcp/oracle_answer/server.py
@@ -0,0 +1,386 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import sys
+from typing import Any, Callable, Dict, List, Optional
+
+from layer0 import layer0_entry
+from layer0.shadow_classifier import ShadowEvalResult
+
+from .tool import OracleAnswerTool
+
+MAX_BYTES_DEFAULT = 32_000
+
+
+def _max_bytes() -> int:
+ raw = (os.getenv("VM_MCP_MAX_BYTES") or "").strip()
+ if not raw:
+ return MAX_BYTES_DEFAULT
+ try:
+ return max(4_096, int(raw))
+ except ValueError:
+ return MAX_BYTES_DEFAULT
+
+
+def _redact(obj: Any) -> Any:
+ sensitive_keys = ("token", "secret", "password", "private", "key", "certificate")
+
+ if isinstance(obj, dict):
+ out: Dict[str, Any] = {}
+ for k, v in obj.items():
+ if any(s in str(k).lower() for s in sensitive_keys):
+ out[k] = ""
+ else:
+ out[k] = _redact(v)
+ return out
+ if isinstance(obj, list):
+ return [_redact(v) for v in obj]
+ if isinstance(obj, str):
+ if obj.startswith("ghp_") or obj.startswith("github_pat_"):
+ return ""
+ return obj
+ return obj
+
+
+def _safe_json(payload: Dict[str, Any]) -> str:
+ payload = _redact(payload)
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
+ if len(raw.encode("utf-8")) <= _max_bytes():
+ return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
+
+ truncated = {
+ "ok": payload.get("ok", True),
+ "truncated": True,
+ "summary": payload.get("summary", "Response exceeded max size; truncated."),
+ "next_steps": payload.get(
+ "next_steps",
+ ["request narrower outputs (e.g., fewer frameworks or shorter question)"],
+ ),
+ }
+ return json.dumps(truncated, ensure_ascii=False, indent=2, default=str)
+
+
+def _mcp_text_result(
+ payload: Dict[str, Any], *, is_error: bool = False
+) -> Dict[str, Any]:
+ result: Dict[str, Any] = {
+ "content": [{"type": "text", "text": _safe_json(payload)}]
+ }
+ if is_error:
+ result["isError"] = True
+ return result
+
+
+TOOLS: List[Dict[str, Any]] = [
+ {
+ "name": "oracle_answer",
+ "description": "Answer a compliance/security question (optionally via NVIDIA LLM) and map to frameworks.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "question": {
+ "type": "string",
+ "description": "The question to answer.",
+ },
+ "frameworks": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Frameworks to reference (e.g., ['NIST-CSF','ISO-27001','GDPR']).",
+ },
+ "mode": {
+ "type": "string",
+ "enum": ["strict", "advisory"],
+ "default": "strict",
+ "description": "strict=conservative, advisory=exploratory.",
+ },
+ "local_only": {
+ "type": "boolean",
+ "description": "If true, skip NVIDIA API calls (uses local-only mode). Defaults to true when NVIDIA_API_KEY is missing.",
+ },
+ },
+ "required": ["question"],
+ },
+ }
+]
+
+
+class OracleAnswerTools:
+ async def oracle_answer(
+ self,
+ *,
+ question: str,
+ frameworks: Optional[List[str]] = None,
+ mode: str = "strict",
+ local_only: Optional[bool] = None,
+ ) -> Dict[str, Any]:
+ routing_action, shadow = layer0_entry(question)
+ if routing_action != "HANDOFF_TO_LAYER1":
+ return _layer0_payload(routing_action, shadow)
+
+ local_only_use = (
+ bool(local_only)
+ if local_only is not None
+ else not bool((os.getenv("NVIDIA_API_KEY") or "").strip())
+ )
+
+ try:
+ tool = OracleAnswerTool(
+ default_frameworks=frameworks,
+ use_local_only=local_only_use,
+ )
+ except Exception as e: # noqa: BLE001
+ return {
+ "ok": False,
+ "summary": str(e),
+ "data": {
+ "local_only": local_only_use,
+ "has_nvidia_api_key": bool(
+ (os.getenv("NVIDIA_API_KEY") or "").strip()
+ ),
+ },
+ "truncated": False,
+ "next_steps": [
+ "Set NVIDIA_API_KEY to enable live answers",
+ "Or call oracle_answer(local_only=true, ...)",
+ ],
+ }
+
+ resp = await tool.answer(question=question, frameworks=frameworks, mode=mode)
+ return {
+ "ok": True,
+ "summary": "Oracle answer generated.",
+ "data": {
+ "question": question,
+ "mode": mode,
+ "frameworks": frameworks or tool.default_frameworks,
+ "local_only": local_only_use,
+ "model": resp.model,
+ "answer": resp.answer,
+ "framework_hits": resp.framework_hits,
+ "reasoning": resp.reasoning,
+ },
+ "truncated": False,
+ "next_steps": [
+ "If the answer is incomplete, add more specifics to the question or include more frameworks.",
+ ],
+ }
+
+
+class StdioJsonRpc:
+ def __init__(self) -> None:
+ self._in = sys.stdin.buffer
+ self._out = sys.stdout.buffer
+ self._mode: str | None = None # "headers" | "line"
+
+ def read_message(self) -> Optional[Dict[str, Any]]:
+ while True:
+ if self._mode == "line":
+ line = self._in.readline()
+ if not line:
+ return None
+ raw = line.decode("utf-8", "replace").strip()
+ if not raw:
+ continue
+ try:
+ msg = json.loads(raw)
+ except Exception:
+ continue
+ if isinstance(msg, dict):
+ return msg
+ continue
+
+ first = self._in.readline()
+ if not first:
+ return None
+
+ if first in (b"\r\n", b"\n"):
+ continue
+
+ # Auto-detect newline-delimited JSON framing.
+ if self._mode is None and first.lstrip().startswith(b"{"):
+ try:
+ msg = json.loads(first.decode("utf-8", "replace"))
+ except Exception:
+ msg = None
+ if isinstance(msg, dict):
+ self._mode = "line"
+ return msg
+
+ headers: Dict[str, str] = {}
+ try:
+ text = first.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ while True:
+ line = self._in.readline()
+ if not line:
+ return None
+ if line in (b"\r\n", b"\n"):
+ break
+ try:
+ text = line.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ if "content-length" not in headers:
+ return None
+ try:
+ length = int(headers["content-length"])
+ except ValueError:
+ return None
+ body = self._in.read(length)
+ if not body:
+ return None
+ self._mode = "headers"
+ msg = json.loads(body.decode("utf-8", "replace"))
+ if isinstance(msg, dict):
+ return msg
+ return None
+
+ def write_message(self, message: Dict[str, Any]) -> None:
+ if self._mode == "line":
+ payload = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ self._out.write(payload + b"\n")
+ self._out.flush()
+ return
+
+ body = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
+ self._out.write(header)
+ self._out.write(body)
+ self._out.flush()
+
+
+def main() -> None:
+ tools = OracleAnswerTools()
+ rpc = StdioJsonRpc()
+
+ handlers: Dict[str, Callable[[Dict[str, Any]], Any]] = {
+ "oracle_answer": lambda a: tools.oracle_answer(**a),
+ }
+
+ while True:
+ msg = rpc.read_message()
+ if msg is None:
+ return
+
+ method = msg.get("method")
+ msg_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ result = {
+ "protocolVersion": "2024-11-05",
+ "serverInfo": {"name": "oracle_answer", "version": "0.1.0"},
+ "capabilities": {"tools": {}},
+ }
+ rpc.write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ continue
+
+ if method == "tools/list":
+ rpc.write_message(
+ {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}}
+ )
+ continue
+
+ if method == "tools/call":
+ tool_name = str(params.get("name") or "")
+ args = params.get("arguments") or {}
+ handler = handlers.get(tool_name)
+ if not handler:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"Unknown tool: {tool_name}",
+ "data": {"known_tools": sorted(handlers.keys())},
+ "truncated": False,
+ "next_steps": ["Call tools/list"],
+ },
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ payload = asyncio.run(handler(args)) # type: ignore[arg-type]
+ is_error = (
+ not bool(payload.get("ok", True))
+ if isinstance(payload, dict)
+ else False
+ )
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(payload, is_error=is_error),
+ }
+ )
+ continue
+
+ # Ignore notifications.
+ if msg_id is None:
+ continue
+
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {"ok": False, "summary": f"Unsupported method: {method}"},
+ is_error=True,
+ ),
+ }
+ )
+ except Exception as e: # noqa: BLE001
+ if msg_id is not None:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {"ok": False, "summary": f"fatal error: {e}"},
+ is_error=True,
+ ),
+ }
+ )
+
+
+def _layer0_payload(routing_action: str, shadow: ShadowEvalResult) -> Dict[str, Any]:
+ if routing_action == "FAIL_CLOSED":
+ return {"ok": False, "summary": "Layer 0: cannot comply with this request."}
+ if routing_action == "HANDOFF_TO_GUARDRAILS":
+ reason = shadow.reason or "governance_violation"
+ return {
+ "ok": False,
+ "summary": f"Layer 0: governance violation detected ({reason}).",
+ }
+ if routing_action == "PROMPT_FOR_CLARIFICATION":
+ return {
+ "ok": False,
+ "summary": "Layer 0: request is ambiguous. Please clarify and retry.",
+ }
+ return {"ok": False, "summary": "Layer 0: unrecognized routing action; refusing."}
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp/oracle_answer/tool.py b/mcp/oracle_answer/tool.py
index ffdb930..bfc4423 100644
--- a/mcp/oracle_answer/tool.py
+++ b/mcp/oracle_answer/tool.py
@@ -9,7 +9,11 @@ Separate from CLI/API wrapper for clean testability.
from __future__ import annotations
+import asyncio
+import json
import os
+import urllib.error
+import urllib.request
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@@ -92,12 +96,10 @@ class OracleAnswerTool:
if self.use_local_only:
return "Local-only mode: skipping NVIDIA API call"
- if not httpx:
- raise ImportError("httpx not installed. Install with: pip install httpx")
-
headers = {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/json",
+ "Content-Type": "application/json",
}
payload = {
@@ -108,18 +110,45 @@ class OracleAnswerTool:
"max_tokens": 1024,
}
- try:
- async with httpx.AsyncClient() as client:
- response = await client.post(
- f"{self.NVIDIA_API_BASE}/chat/completions",
- json=payload,
- headers=headers,
- timeout=30.0,
- )
- response.raise_for_status()
- data = response.json()
+ # Prefer httpx when available; otherwise fall back to stdlib urllib to avoid extra deps.
+ if httpx:
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.NVIDIA_API_BASE}/chat/completions",
+ json=payload,
+ headers=headers,
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+ return data["choices"][0]["message"]["content"]
+ except Exception as e: # noqa: BLE001
+ return f"(API Error: {str(e)}) Falling back to local analysis..."
+
+ def _urllib_post() -> str:
+ req = urllib.request.Request(
+ url=f"{self.NVIDIA_API_BASE}/chat/completions",
+ method="POST",
+ headers=headers,
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ raw = resp.read().decode("utf-8", "replace")
+ data = json.loads(raw)
return data["choices"][0]["message"]["content"]
- except Exception as e:
+ except urllib.error.HTTPError as e:
+ detail = ""
+ try:
+ detail = e.read().decode("utf-8", "replace")
+ except Exception:
+ detail = str(e)
+ raise RuntimeError(f"HTTP {e.code}: {detail}") from e
+
+ try:
+ return await asyncio.to_thread(_urllib_post)
+ except Exception as e: # noqa: BLE001
return f"(API Error: {str(e)}) Falling back to local analysis..."
async def answer(
diff --git a/mcp/waf_intelligence/__init__.py b/mcp/waf_intelligence/__init__.py
index 9ae990c..c9d5649 100644
--- a/mcp/waf_intelligence/__init__.py
+++ b/mcp/waf_intelligence/__init__.py
@@ -10,22 +10,24 @@ This module provides tools to:
Export primary classes and functions:
"""
-from mcp.waf_intelligence.analyzer import (
- WAFRuleAnalyzer,
- RuleViolation,
+__version__ = "0.3.0"
+
+from .analyzer import (
AnalysisResult,
+ RuleViolation,
+ WAFRuleAnalyzer,
)
-from mcp.waf_intelligence.generator import (
- WAFRuleGenerator,
- GeneratedRule,
-)
-from mcp.waf_intelligence.compliance import (
+from .compliance import (
ComplianceMapper,
FrameworkMapping,
)
-from mcp.waf_intelligence.orchestrator import (
- WAFIntelligence,
+from .generator import (
+ GeneratedRule,
+ WAFRuleGenerator,
+)
+from .orchestrator import (
WAFInsight,
+ WAFIntelligence,
)
__all__ = [
diff --git a/mcp/waf_intelligence/__main__.py b/mcp/waf_intelligence/__main__.py
index 4dd4e06..9b07a23 100644
--- a/mcp/waf_intelligence/__main__.py
+++ b/mcp/waf_intelligence/__main__.py
@@ -10,6 +10,7 @@ from typing import Any, Dict, List
from layer0 import layer0_entry
from layer0.shadow_classifier import ShadowEvalResult
+from . import __version__ as WAF_INTEL_VERSION
from .orchestrator import WAFInsight, WAFIntelligence
@@ -56,11 +57,18 @@ def run_cli(argv: List[str] | None = None) -> int:
action="store_true",
help="Exit with non-zero code if any error-severity violations are found.",
)
+ parser.add_argument(
+ "--version",
+ action="version",
+ version=f"%(prog)s {WAF_INTEL_VERSION}",
+ )
args = parser.parse_args(argv)
# Layer 0: pre-boot Shadow Eval gate.
- routing_action, shadow = layer0_entry(f"waf_intel_cli file={args.file} limit={args.limit}")
+ routing_action, shadow = layer0_entry(
+ f"waf_intel_cli file={args.file} limit={args.limit}"
+ )
if routing_action != "HANDOFF_TO_LAYER1":
_render_layer0_block(routing_action, shadow)
return 1
@@ -90,7 +98,9 @@ def run_cli(argv: List[str] | None = None) -> int:
print(f"\nWAF Intelligence Report for: {path}\n{'-' * 72}")
if not insights:
- print("No high-severity, high-confidence issues detected based on current heuristics.")
+ print(
+ "No high-severity, high-confidence issues detected based on current heuristics."
+ )
return 0
for idx, insight in enumerate(insights, start=1):
@@ -119,7 +129,9 @@ def run_cli(argv: List[str] | None = None) -> int:
if insight.mappings:
print("\nCompliance Mapping:")
for mapping in insight.mappings:
- print(f" - {mapping.framework} {mapping.control_id}: {mapping.description}")
+ print(
+ f" - {mapping.framework} {mapping.control_id}: {mapping.description}"
+ )
print()
diff --git a/mcp/waf_intelligence/analyzer.py b/mcp/waf_intelligence/analyzer.py
index 4f7de91..2291c22 100644
--- a/mcp/waf_intelligence/analyzer.py
+++ b/mcp/waf_intelligence/analyzer.py
@@ -1,9 +1,16 @@
from __future__ import annotations
+import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
+MANAGED_WAF_RULESET_IDS = (
+ # Cloudflare managed WAF ruleset IDs (last updated 2025-12-18).
+ "efb7b8c949ac4650a09736fc376e9aee", # Cloudflare Managed Ruleset
+ "4814384a9e5d4991b9815dcfc25d2f1f", # OWASP Core Ruleset
+)
+
@dataclass
class RuleViolation:
@@ -57,6 +64,20 @@ class WAFRuleAnalyzer:
Analyze Cloudflare WAF rules from Terraform with a quality-first posture.
"""
+ def _has_managed_waf_rules(self, text: str) -> bool:
+ text_lower = text.lower()
+
+ if "managed_rules" in text_lower:
+ return True
+
+ if re.search(r'phase\s*=\s*"http_request_firewall_managed"', text_lower):
+ return True
+
+ if "cf.waf" in text_lower:
+ return True
+
+ return any(ruleset_id in text_lower for ruleset_id in MANAGED_WAF_RULESET_IDS)
+
def analyze_file(
self,
path: str | Path,
@@ -70,7 +91,7 @@ class WAFRuleAnalyzer:
violations: List[RuleViolation] = []
# Example heuristic: no managed rules present
- if "managed_rules" not in text:
+ if not self._has_managed_waf_rules(text):
violations.append(
RuleViolation(
rule_id=None,
@@ -102,7 +123,7 @@ class WAFRuleAnalyzer:
violations=violations,
metadata={
"file_size": path.stat().st_size,
- "heuristics_version": "0.2.0",
+ "heuristics_version": "0.3.0",
},
)
@@ -125,7 +146,7 @@ class WAFRuleAnalyzer:
tmp_path = Path(source_name)
violations: List[RuleViolation] = []
- if "managed_rules" not in text:
+ if not self._has_managed_waf_rules(text):
violations.append(
RuleViolation(
rule_id=None,
@@ -141,7 +162,7 @@ class WAFRuleAnalyzer:
result = AnalysisResult(
source=str(tmp_path),
violations=violations,
- metadata={"heuristics_version": "0.2.0"},
+ metadata={"heuristics_version": "0.3.0"},
)
result.violations = result.top_violations(
@@ -161,27 +182,37 @@ class WAFRuleAnalyzer:
) -> AnalysisResult:
"""
Enhanced analysis using threat intelligence data.
-
+
Args:
path: WAF config file path
threat_indicators: List of ThreatIndicator objects from threat_intel module
min_severity: Minimum severity to include
min_confidence: Minimum confidence threshold
-
+
Returns:
AnalysisResult with violations informed by threat intel
"""
# Start with base analysis
- base_result = self.analyze_file(path, min_severity=min_severity, min_confidence=min_confidence)
-
+ base_result = self.analyze_file(
+ path, min_severity=min_severity, min_confidence=min_confidence
+ )
+
path = Path(path)
text = path.read_text(encoding="utf-8")
text_lower = text.lower()
-
+
# Check if threat indicators are addressed by existing rules
- critical_ips = [i for i in threat_indicators if i.indicator_type == "ip" and i.severity in ("critical", "high")]
- critical_patterns = [i for i in threat_indicators if i.indicator_type == "pattern" and i.severity in ("critical", "high")]
-
+ critical_ips = [
+ i
+ for i in threat_indicators
+ if i.indicator_type == "ip" and i.severity in ("critical", "high")
+ ]
+ critical_patterns = [
+ i
+ for i in threat_indicators
+ if i.indicator_type == "pattern" and i.severity in ("critical", "high")
+ ]
+
# Check for IP blocking coverage
if critical_ips:
ip_block_present = "ip.src" in text_lower or "cf.client.ip" in text_lower
@@ -197,14 +228,14 @@ class WAFRuleAnalyzer:
hint=f"Add IP blocking rules for identified threat actors. Sample IPs: {', '.join(i.value for i in critical_ips[:3])}",
)
)
-
+
# Check for pattern-based attack coverage
attack_types_seen = set()
for ind in critical_patterns:
for tag in ind.tags:
if tag in ("sqli", "xss", "rce", "path_traversal"):
attack_types_seen.add(tag)
-
+
# Check managed ruleset coverage
for attack_type in attack_types_seen:
if attack_type not in text_lower and f'"{attack_type}"' not in text_lower:
@@ -219,13 +250,12 @@ class WAFRuleAnalyzer:
hint=f"Enable Cloudflare managed rules for {attack_type.upper()} protection.",
)
)
-
+
# Update metadata with threat intel stats
base_result.metadata["threat_intel"] = {
"critical_ips": len(critical_ips),
"critical_patterns": len(critical_patterns),
"attack_types_seen": list(attack_types_seen),
}
-
- return base_result
+ return base_result
diff --git a/mcp/waf_intelligence/mcp_server.py b/mcp/waf_intelligence/mcp_server.py
new file mode 100644
index 0000000..813f41e
--- /dev/null
+++ b/mcp/waf_intelligence/mcp_server.py
@@ -0,0 +1,632 @@
+from __future__ import annotations
+
+import glob
+import json
+import os
+import sys
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional
+
+from cloudflare.layer0 import layer0_entry
+from cloudflare.layer0.shadow_classifier import ShadowEvalResult
+
+from .orchestrator import ThreatAssessment, WAFInsight, WAFIntelligence
+
+MAX_BYTES_DEFAULT = 32_000
+
+
+def _cloudflare_root() -> Path:
+ # mcp_server.py -> waf_intelligence -> mcp -> cloudflare
+ return Path(__file__).resolve().parents[2]
+
+
+def _max_bytes() -> int:
+ raw = (os.getenv("VM_MCP_MAX_BYTES") or "").strip()
+ if not raw:
+ return MAX_BYTES_DEFAULT
+ try:
+ return max(4_096, int(raw))
+ except ValueError:
+ return MAX_BYTES_DEFAULT
+
+
+def _redact(obj: Any) -> Any:
+ sensitive_keys = ("token", "secret", "password", "private", "key", "certificate")
+
+ if isinstance(obj, dict):
+ out: Dict[str, Any] = {}
+ for k, v in obj.items():
+ if any(s in str(k).lower() for s in sensitive_keys):
+ out[k] = ""
+ else:
+ out[k] = _redact(v)
+ return out
+ if isinstance(obj, list):
+ return [_redact(v) for v in obj]
+ if isinstance(obj, str):
+ if obj.startswith("ghp_") or obj.startswith("github_pat_"):
+ return ""
+ return obj
+ return obj
+
+
+def _safe_json(payload: Dict[str, Any]) -> str:
+ payload = _redact(payload)
+ raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str)
+ if len(raw.encode("utf-8")) <= _max_bytes():
+ return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
+
+ truncated = {
+ "ok": payload.get("ok", True),
+ "truncated": True,
+ "summary": payload.get("summary", "Response exceeded max size; truncated."),
+ "next_steps": payload.get(
+ "next_steps",
+ [
+ "request fewer files/insights (limit=...)",
+ "use higher min_severity to reduce output",
+ ],
+ ),
+ }
+ return json.dumps(truncated, ensure_ascii=False, indent=2, default=str)
+
+
+def _mcp_text_result(
+ payload: Dict[str, Any], *, is_error: bool = False
+) -> Dict[str, Any]:
+ result: Dict[str, Any] = {
+ "content": [{"type": "text", "text": _safe_json(payload)}]
+ }
+ if is_error:
+ result["isError"] = True
+ return result
+
+
+def _insight_to_dict(insight: WAFInsight) -> Dict[str, Any]:
+ return asdict(insight)
+
+
+def _assessment_to_dict(assessment: ThreatAssessment) -> Dict[str, Any]:
+ violations = []
+ if assessment.analysis_result and getattr(
+ assessment.analysis_result, "violations", None
+ ):
+ violations = list(assessment.analysis_result.violations)
+
+ severity_counts = {"error": 0, "warning": 0, "info": 0}
+ for v in violations:
+ sev = getattr(v, "severity", "info")
+ if sev in severity_counts:
+ severity_counts[sev] += 1
+
+ return {
+ "risk_score": assessment.risk_score,
+ "risk_level": assessment.risk_level,
+ "classification_summary": assessment.classification_summary,
+ "recommended_actions": assessment.recommended_actions,
+ "analysis": {
+ "has_config_analysis": assessment.analysis_result is not None,
+ "violations_total": len(violations),
+ "violations_by_severity": severity_counts,
+ },
+ "has_threat_intel": assessment.threat_report is not None,
+ "generated_at": str(assessment.generated_at),
+ }
+
+
+TOOLS: List[Dict[str, Any]] = [
+ {
+ "name": "waf_capabilities",
+ "description": "List available WAF Intelligence capabilities.",
+ "inputSchema": {"type": "object", "properties": {}},
+ },
+ {
+ "name": "analyze_waf",
+ "description": "Analyze Terraform WAF file(s) and return curated insights (legacy alias for waf_analyze).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string",
+ "description": "Single file path to analyze.",
+ },
+ "files": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "List of file paths or glob patterns to analyze.",
+ },
+ "limit": {
+ "type": "integer",
+ "default": 3,
+ "description": "Max insights per file.",
+ },
+ "severity_threshold": {
+ "type": "string",
+ "enum": ["info", "warning", "error"],
+ "default": "warning",
+ "description": "Minimum severity to include (alias for min_severity).",
+ },
+ },
+ },
+ },
+ {
+ "name": "waf_analyze",
+ "description": "Analyze Terraform WAF file(s) and return curated insights (requires file or files).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "type": "string",
+ "description": "Single file path to analyze.",
+ },
+ "files": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "List of file paths or glob patterns to analyze.",
+ },
+ "limit": {
+ "type": "integer",
+ "default": 3,
+ "description": "Max insights per file.",
+ },
+ "min_severity": {
+ "type": "string",
+ "enum": ["info", "warning", "error"],
+ "default": "warning",
+ "description": "Minimum severity to include.",
+ },
+ },
+ },
+ },
+ {
+ "name": "waf_assess",
+ "description": "Run a broader assessment (optionally includes threat intel collection).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "waf_config_path": {
+ "type": "string",
+ "description": "Path to Terraform WAF config (default: terraform/waf.tf).",
+ },
+ "include_threat_intel": {
+ "type": "boolean",
+ "default": False,
+ "description": "If true, attempt to collect threat intel (may require network and credentials).",
+ },
+ },
+ },
+ },
+ {
+ "name": "waf_generate_gitops_proposals",
+ "description": "Generate GitOps-ready rule proposals (best-effort; requires threat intel to produce output).",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "waf_config_path": {
+ "type": "string",
+ "description": "Path to Terraform WAF config (default: terraform/waf.tf).",
+ },
+ "include_threat_intel": {
+ "type": "boolean",
+ "default": True,
+ "description": "Attempt to collect threat intel before proposing rules.",
+ },
+ "max_proposals": {
+ "type": "integer",
+ "default": 5,
+ "description": "Maximum proposals to generate.",
+ },
+ },
+ },
+ },
+]
+
+
+class WafIntelligenceTools:
+ def __init__(self) -> None:
+ self.workspace_root = _cloudflare_root()
+ self.repo_root = self.workspace_root.parent
+ self.waf = WAFIntelligence(workspace_path=str(self.workspace_root))
+
+ def _resolve_path(self, raw: str) -> Path:
+ path = Path(raw)
+ if path.is_absolute():
+ return path
+
+ candidates = [
+ Path.cwd() / path,
+ self.workspace_root / path,
+ self.repo_root / path,
+ ]
+ for candidate in candidates:
+ if candidate.exists():
+ return candidate
+ return self.workspace_root / path
+
+ def waf_capabilities(self) -> Dict[str, Any]:
+ return {
+ "ok": True,
+ "summary": "WAF Intelligence capabilities.",
+ "data": {"capabilities": self.waf.capabilities},
+ "truncated": False,
+ "next_steps": [
+ "Call waf_analyze(file=..., limit=...) to analyze config.",
+ "Call waf_assess(include_threat_intel=true) for a broader assessment.",
+ ],
+ }
+
+ def waf_analyze(
+ self,
+ *,
+ file: Optional[str] = None,
+ files: Optional[List[str]] = None,
+ limit: int = 3,
+ min_severity: str = "warning",
+ ) -> Dict[str, Any]:
+ paths: List[str] = []
+ if files:
+ for pattern in files:
+ paths.extend(glob.glob(pattern))
+ if file:
+ paths.append(file)
+
+ seen = set()
+ unique_paths: List[str] = []
+ for p in paths:
+ if p not in seen:
+ seen.add(p)
+ unique_paths.append(p)
+
+ if not unique_paths:
+ return {
+ "ok": False,
+ "summary": "Provide 'file' or 'files' to analyze.",
+ "truncated": False,
+ "next_steps": ["Call waf_analyze(file='terraform/waf.tf')"],
+ }
+
+ results: List[Dict[str, Any]] = []
+ for p in unique_paths:
+ path = self._resolve_path(p)
+ if not path.exists():
+ results.append(
+ {
+ "file": str(path),
+ "ok": False,
+ "summary": "File not found.",
+ }
+ )
+ continue
+
+ insights = self.waf.analyze_and_recommend(
+ str(path),
+ limit=limit,
+ min_severity=min_severity,
+ )
+ results.append(
+ {
+ "file": str(path),
+ "ok": True,
+ "insights": [_insight_to_dict(i) for i in insights],
+ }
+ )
+
+ ok = all(r.get("ok") for r in results)
+ return {
+ "ok": ok,
+ "summary": f"Analyzed {len(results)} file(s).",
+ "data": {"results": results},
+ "truncated": False,
+ "next_steps": [
+ "Raise/lower min_severity or limit to tune output size.",
+ ],
+ }
+
+ def waf_assess(
+ self,
+ *,
+ waf_config_path: Optional[str] = None,
+ include_threat_intel: bool = False,
+ ) -> Dict[str, Any]:
+ waf_config_path_resolved = (
+ str(self._resolve_path(waf_config_path)) if waf_config_path else None
+ )
+ assessment = self.waf.full_assessment(
+ waf_config_path=waf_config_path_resolved,
+ include_threat_intel=include_threat_intel,
+ )
+ return {
+ "ok": True,
+ "summary": "WAF assessment complete.",
+ "data": _assessment_to_dict(assessment),
+ "truncated": False,
+ "next_steps": [
+ "Call waf_generate_gitops_proposals(...) to draft Terraform rule proposals (best-effort).",
+ ],
+ }
+
+ def waf_generate_gitops_proposals(
+ self,
+ *,
+ waf_config_path: Optional[str] = None,
+ include_threat_intel: bool = True,
+ max_proposals: int = 5,
+ ) -> Dict[str, Any]:
+ waf_config_path_resolved = (
+ str(self._resolve_path(waf_config_path)) if waf_config_path else None
+ )
+ assessment = self.waf.full_assessment(
+ waf_config_path=waf_config_path_resolved,
+ include_threat_intel=include_threat_intel,
+ )
+ proposals = self.waf.generate_gitops_proposals(
+ threat_report=assessment.threat_report,
+ max_proposals=max_proposals,
+ )
+ return {
+ "ok": True,
+ "summary": f"Generated {len(proposals)} proposal(s).",
+ "data": {
+ "assessment": _assessment_to_dict(assessment),
+ "proposals": proposals,
+ },
+ "truncated": False,
+ "next_steps": [
+ "If proposals are empty, enable threat intel and ensure required credentials/log sources exist.",
+ ],
+ }
+
+
+class StdioJsonRpc:
+ def __init__(self) -> None:
+ self._in = sys.stdin.buffer
+ self._out = sys.stdout.buffer
+ self._mode: str | None = None # "headers" | "line"
+
+ def read_message(self) -> Optional[Dict[str, Any]]:
+ while True:
+ if self._mode == "line":
+ line = self._in.readline()
+ if not line:
+ return None
+ raw = line.decode("utf-8", "replace").strip()
+ if not raw:
+ continue
+ try:
+ msg = json.loads(raw)
+ except Exception:
+ continue
+ if isinstance(msg, dict):
+ return msg
+ continue
+
+ first = self._in.readline()
+ if not first:
+ return None
+
+ if first in (b"\r\n", b"\n"):
+ continue
+
+ # Auto-detect newline-delimited JSON framing.
+ if self._mode is None and first.lstrip().startswith(b"{"):
+ try:
+ msg = json.loads(first.decode("utf-8", "replace"))
+ except Exception:
+ msg = None
+ if isinstance(msg, dict):
+ self._mode = "line"
+ return msg
+
+ headers: Dict[str, str] = {}
+ try:
+ text = first.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ while True:
+ line = self._in.readline()
+ if not line:
+ return None
+ if line in (b"\r\n", b"\n"):
+ break
+ try:
+ text = line.decode("utf-8", "replace").strip()
+ except Exception:
+ continue
+ if ":" not in text:
+ continue
+ k, v = text.split(":", 1)
+ headers[k.lower().strip()] = v.strip()
+
+ if "content-length" not in headers:
+ return None
+ try:
+ length = int(headers["content-length"])
+ except ValueError:
+ return None
+ body = self._in.read(length)
+ if not body:
+ return None
+ self._mode = "headers"
+ msg = json.loads(body.decode("utf-8", "replace"))
+ if isinstance(msg, dict):
+ return msg
+ return None
+
+ def write_message(self, message: Dict[str, Any]) -> None:
+ if self._mode == "line":
+ payload = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ self._out.write(payload + b"\n")
+ self._out.flush()
+ return
+
+ body = json.dumps(
+ message, ensure_ascii=False, separators=(",", ":"), default=str
+ ).encode("utf-8")
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("utf-8")
+ self._out.write(header)
+ self._out.write(body)
+ self._out.flush()
+
+
+def main() -> None:
+ tools = WafIntelligenceTools()
+ rpc = StdioJsonRpc()
+
+ handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
+ "waf_capabilities": lambda a: tools.waf_capabilities(),
+ "analyze_waf": lambda a: tools.waf_analyze(
+ file=a.get("file"),
+ files=a.get("files"),
+ limit=int(a.get("limit", 3)),
+ min_severity=str(a.get("severity_threshold", "warning")),
+ ),
+ "waf_analyze": lambda a: tools.waf_analyze(**a),
+ "waf_assess": lambda a: tools.waf_assess(**a),
+ "waf_generate_gitops_proposals": lambda a: tools.waf_generate_gitops_proposals(
+ **a
+ ),
+ }
+
+ while True:
+ msg = rpc.read_message()
+ if msg is None:
+ return
+
+ method = msg.get("method")
+ msg_id = msg.get("id")
+ params = msg.get("params") or {}
+
+ try:
+ if method == "initialize":
+ result = {
+ "protocolVersion": "2024-11-05",
+ "serverInfo": {"name": "waf_intelligence", "version": "0.1.0"},
+ "capabilities": {"tools": {}},
+ }
+ rpc.write_message({"jsonrpc": "2.0", "id": msg_id, "result": result})
+ continue
+
+ if method == "tools/list":
+ rpc.write_message(
+ {"jsonrpc": "2.0", "id": msg_id, "result": {"tools": TOOLS}}
+ )
+ continue
+
+ if method == "tools/call":
+ tool_name = str(params.get("name") or "")
+ args = params.get("arguments") or {}
+
+ routing_action, shadow = layer0_entry(
+ _shadow_query_repr(tool_name, args)
+ )
+ if routing_action != "HANDOFF_TO_LAYER1":
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ _layer0_payload(routing_action, shadow), is_error=True
+ ),
+ }
+ )
+ continue
+
+ handler = handlers.get(tool_name)
+ if not handler:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {
+ "ok": False,
+ "summary": f"Unknown tool: {tool_name}",
+ "data": {"known_tools": sorted(handlers.keys())},
+ "truncated": False,
+ "next_steps": ["Call tools/list"],
+ },
+ is_error=True,
+ ),
+ }
+ )
+ continue
+
+ payload = handler(args)
+ is_error = (
+ not bool(payload.get("ok", True))
+ if isinstance(payload, dict)
+ else False
+ )
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(payload, is_error=is_error),
+ }
+ )
+ continue
+
+ # Ignore notifications.
+ if msg_id is None:
+ continue
+
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {"ok": False, "summary": f"Unsupported method: {method}"},
+ is_error=True,
+ ),
+ }
+ )
+ except Exception as e: # noqa: BLE001
+ if msg_id is not None:
+ rpc.write_message(
+ {
+ "jsonrpc": "2.0",
+ "id": msg_id,
+ "result": _mcp_text_result(
+ {"ok": False, "summary": f"fatal error: {e}"},
+ is_error=True,
+ ),
+ }
+ )
+
+
+def _shadow_query_repr(tool_name: str, tool_args: Dict[str, Any]) -> str:
+ if tool_name == "waf_capabilities":
+ return "List WAF Intelligence capabilities."
+ try:
+ return f"{tool_name}: {json.dumps(tool_args, sort_keys=True, default=str)}"
+ except Exception:
+ return f"{tool_name}: {str(tool_args)}"
+
+
+def _layer0_payload(routing_action: str, shadow: ShadowEvalResult) -> Dict[str, Any]:
+ if routing_action == "FAIL_CLOSED":
+ return {"ok": False, "summary": "Layer 0: cannot comply with this request."}
+ if routing_action == "HANDOFF_TO_GUARDRAILS":
+ reason = shadow.reason or "governance_violation"
+ return {
+ "ok": False,
+ "summary": f"Layer 0: governance violation detected ({reason}).",
+ }
+ if routing_action == "PROMPT_FOR_CLARIFICATION":
+ return {
+ "ok": False,
+ "summary": "Layer 0: request is ambiguous. Please clarify and retry.",
+ }
+ return {"ok": False, "summary": "Layer 0: unrecognized routing action; refusing."}
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mcp/waf_intelligence/orchestrator.py b/mcp/waf_intelligence/orchestrator.py
index cac7e28..9bb210f 100644
--- a/mcp/waf_intelligence/orchestrator.py
+++ b/mcp/waf_intelligence/orchestrator.py
@@ -6,27 +6,26 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
-from mcp.waf_intelligence.analyzer import AnalysisResult, RuleViolation, WAFRuleAnalyzer
-from mcp.waf_intelligence.compliance import ComplianceMapper, FrameworkMapping
-from mcp.waf_intelligence.generator import GeneratedRule, WAFRuleGenerator
+from .analyzer import AnalysisResult, RuleViolation, WAFRuleAnalyzer
+from .compliance import ComplianceMapper, FrameworkMapping
+from .generator import GeneratedRule, WAFRuleGenerator
# Optional advanced modules (Phase 7)
try:
- from mcp.waf_intelligence.threat_intel import (
+ from .threat_intel import (
ThreatIntelCollector,
ThreatIntelReport,
ThreatIndicator,
)
+
_HAS_THREAT_INTEL = True
except ImportError:
_HAS_THREAT_INTEL = False
ThreatIntelCollector = None
try:
- from mcp.waf_intelligence.classifier import (
- ThreatClassifier,
- ClassificationResult,
- )
+ from .classifier import ThreatClassifier
+
_HAS_CLASSIFIER = True
except ImportError:
_HAS_CLASSIFIER = False
@@ -45,14 +44,14 @@ class WAFInsight:
@dataclass
class ThreatAssessment:
"""Phase 7: Comprehensive threat assessment result."""
-
+
analysis_result: Optional[AnalysisResult] = None
threat_report: Optional[Any] = None # ThreatIntelReport when available
classification_summary: Dict[str, int] = field(default_factory=dict)
risk_score: float = 0.0
recommended_actions: List[str] = field(default_factory=list)
generated_at: datetime = field(default_factory=datetime.utcnow)
-
+
@property
def risk_level(self) -> str:
if self.risk_score >= 0.8:
@@ -81,22 +80,22 @@ class WAFIntelligence:
enable_ml_classifier: bool = True,
) -> None:
self.workspace = Path(workspace_path) if workspace_path else Path.cwd()
-
+
# Core components
self.analyzer = WAFRuleAnalyzer()
self.generator = WAFRuleGenerator()
self.mapper = ComplianceMapper()
-
+
# Phase 7 components (optional)
self.threat_intel: Optional[Any] = None
self.classifier: Optional[Any] = None
-
+
if enable_threat_intel and _HAS_THREAT_INTEL:
try:
self.threat_intel = ThreatIntelCollector()
except Exception:
pass
-
+
if enable_ml_classifier and _HAS_CLASSIFIER:
try:
self.classifier = ThreatClassifier()
@@ -149,24 +148,24 @@ class WAFIntelligence:
) -> Optional[Any]:
"""
Collect threat intelligence from logs and external feeds.
-
+
Args:
log_paths: Paths to Cloudflare log files
max_indicators: Maximum indicators to collect
-
+
Returns:
ThreatIntelReport or None if unavailable
"""
if not self.threat_intel:
return None
-
+
# Default log paths
if log_paths is None:
log_paths = [
str(self.workspace / "logs"),
"/var/log/cloudflare",
]
-
+
return self.threat_intel.collect(
log_paths=log_paths,
max_indicators=max_indicators,
@@ -175,16 +174,16 @@ class WAFIntelligence:
def classify_threat(self, payload: str) -> Optional[Any]:
"""
Classify a payload using ML classifier.
-
+
Args:
payload: Request payload to classify
-
+
Returns:
ClassificationResult or None
"""
if not self.classifier:
return None
-
+
return self.classifier.classify(payload)
def full_assessment(
@@ -195,51 +194,52 @@ class WAFIntelligence:
) -> ThreatAssessment:
"""
Phase 7: Perform comprehensive threat assessment.
-
+
Combines:
- WAF configuration analysis
- Threat intelligence collection
- ML classification summary
- Risk scoring
-
+
Args:
waf_config_path: Path to WAF Terraform file
log_paths: Paths to log files
include_threat_intel: Whether to collect threat intel
-
+
Returns:
ThreatAssessment with full analysis results
"""
assessment = ThreatAssessment()
risk_factors: List[float] = []
recommendations: List[str] = []
-
+
# 1. Analyze WAF configuration
if waf_config_path is None:
waf_config_path = str(self.workspace / "terraform" / "waf.tf")
-
+
if Path(waf_config_path).exists():
assessment.analysis_result = self.analyzer.analyze_file(
waf_config_path,
min_severity="info",
)
-
+
# Calculate risk from violations
severity_weights = {"error": 0.8, "warning": 0.5, "info": 0.2}
for violation in assessment.analysis_result.violations:
weight = severity_weights.get(violation.severity, 0.3)
risk_factors.append(weight)
-
+
# Generate recommendations
critical_count = sum(
- 1 for v in assessment.analysis_result.violations
+ 1
+ for v in assessment.analysis_result.violations
if v.severity == "error"
)
if critical_count > 0:
recommendations.append(
f"🔴 Fix {critical_count} critical WAF configuration issues"
)
-
+
# 2. Collect threat intelligence
if include_threat_intel and self.threat_intel:
try:
@@ -247,52 +247,55 @@ class WAFIntelligence:
log_paths=log_paths,
max_indicators=50,
)
-
+
if assessment.threat_report:
indicators = assessment.threat_report.indicators
-
+
# Count by severity
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for ind in indicators:
sev = getattr(ind, "severity", "low")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
-
+
# Add to classification summary
- assessment.classification_summary["threat_indicators"] = len(indicators)
+ assessment.classification_summary["threat_indicators"] = len(
+ indicators
+ )
assessment.classification_summary.update(severity_counts)
-
+
# Calculate threat intel risk
if indicators:
critical_ratio = severity_counts["critical"] / len(indicators)
high_ratio = severity_counts["high"] / len(indicators)
risk_factors.append(critical_ratio * 0.9 + high_ratio * 0.7)
-
+
if severity_counts["critical"] > 0:
recommendations.append(
f"🚨 Block {severity_counts['critical']} critical threat IPs immediately"
)
except Exception:
pass
-
+
# 3. ML classification summary (from any collected data)
if self.classifier and assessment.threat_report:
try:
attack_types = {"sqli": 0, "xss": 0, "rce": 0, "clean": 0, "unknown": 0}
-
+
indicators = assessment.threat_report.indicators
pattern_indicators = [
- i for i in indicators
+ i
+ for i in indicators
if getattr(i, "indicator_type", "") == "pattern"
]
-
+
for ind in pattern_indicators[:20]: # Sample first 20
result = self.classifier.classify(ind.value)
if result:
label = result.label
attack_types[label] = attack_types.get(label, 0) + 1
-
+
assessment.classification_summary["ml_classifications"] = attack_types
-
+
# Add ML risk factor
dangerous = attack_types.get("sqli", 0) + attack_types.get("rce", 0)
if dangerous > 5:
@@ -302,15 +305,17 @@ class WAFIntelligence:
)
except Exception:
pass
-
+
# 4. Calculate final risk score
if risk_factors:
- assessment.risk_score = min(1.0, sum(risk_factors) / max(len(risk_factors), 1))
+ assessment.risk_score = min(
+ 1.0, sum(risk_factors) / max(len(risk_factors), 1)
+ )
else:
assessment.risk_score = 0.3 # Baseline risk
-
+
assessment.recommended_actions = recommendations
-
+
return assessment
def generate_gitops_proposals(
@@ -320,42 +325,44 @@ class WAFIntelligence:
) -> List[Dict[str, Any]]:
"""
Generate GitOps-ready rule proposals.
-
+
Args:
threat_report: ThreatIntelReport to use
max_proposals: Maximum proposals to generate
-
+
Returns:
List of proposal dicts ready for MR creation
"""
proposals: List[Dict[str, Any]] = []
-
+
if not threat_report:
return proposals
-
+
try:
# Import proposer dynamically
from gitops.waf_rule_proposer import WAFRuleProposer
-
+
proposer = WAFRuleProposer(workspace_path=str(self.workspace))
batch = proposer.generate_proposals(
threat_report=threat_report,
max_proposals=max_proposals,
)
-
+
for proposal in batch.proposals:
- proposals.append({
- "name": proposal.rule_name,
- "type": proposal.rule_type,
- "severity": proposal.severity,
- "confidence": proposal.confidence,
- "terraform": proposal.terraform_code,
- "justification": proposal.justification,
- "auto_deploy": proposal.auto_deploy_eligible,
- })
+ proposals.append(
+ {
+ "name": proposal.rule_name,
+ "type": proposal.rule_type,
+ "severity": proposal.severity,
+ "confidence": proposal.confidence,
+ "terraform": proposal.terraform_code,
+ "justification": proposal.justification,
+ "auto_deploy": proposal.auto_deploy_eligible,
+ }
+ )
except ImportError:
pass
-
+
return proposals
@property
diff --git a/mcp/waf_intelligence/server.py b/mcp/waf_intelligence/server.py
old mode 100755
new mode 100644
index 9edbba8..b1ce232
--- a/mcp/waf_intelligence/server.py
+++ b/mcp/waf_intelligence/server.py
@@ -1,326 +1,14 @@
#!/usr/bin/env python3
-"""
-WAF Intelligence MCP Server for VS Code Copilot.
+from __future__ import annotations
-This implements the Model Context Protocol (MCP) stdio interface
-so VS Code can communicate with your WAF Intelligence system.
+"""
+Deprecated entrypoint kept for older editor configs.
+
+Use `python3 -m mcp.waf_intelligence.mcp_server` (or `waf_intel_mcp.py`) instead.
"""
-import json
-import sys
-from typing import Any
-
-# Add parent to path for imports
-sys.path.insert(0, '/Users/sovereign/Desktop/CLOUDFLARE')
-
-from mcp.waf_intelligence.orchestrator import WAFIntelligence
-from mcp.waf_intelligence.analyzer import WAFRuleAnalyzer
-from layer0 import layer0_entry
-from layer0.shadow_classifier import ShadowEvalResult
-
-
-class WAFIntelligenceMCPServer:
- """MCP Server wrapper for WAF Intelligence."""
-
- def __init__(self):
- self.waf = WAFIntelligence()
- self.analyzer = WAFRuleAnalyzer()
-
- def get_capabilities(self) -> dict:
- """Return server capabilities."""
- return {
- "tools": [
- {
- "name": "waf_analyze",
- "description": "Analyze WAF logs and detect attack patterns",
- "inputSchema": {
- "type": "object",
- "properties": {
- "log_file": {
- "type": "string",
- "description": "Path to WAF log file (optional)"
- },
- "zone_id": {
- "type": "string",
- "description": "Cloudflare zone ID (optional)"
- }
- }
- }
- },
- {
- "name": "waf_assess",
- "description": "Run full security assessment with threat intel and ML classification",
- "inputSchema": {
- "type": "object",
- "properties": {
- "zone_id": {
- "type": "string",
- "description": "Cloudflare zone ID"
- }
- },
- "required": ["zone_id"]
- }
- },
- {
- "name": "waf_generate_rules",
- "description": "Generate Terraform WAF rules from threat intelligence",
- "inputSchema": {
- "type": "object",
- "properties": {
- "zone_id": {
- "type": "string",
- "description": "Cloudflare zone ID"
- },
- "min_confidence": {
- "type": "number",
- "description": "Minimum confidence threshold (0-1)",
- "default": 0.7
- }
- },
- "required": ["zone_id"]
- }
- },
- {
- "name": "waf_capabilities",
- "description": "List available WAF Intelligence capabilities",
- "inputSchema": {
- "type": "object",
- "properties": {}
- }
- }
- ]
- }
-
- def handle_tool_call(self, name: str, arguments: dict) -> dict:
- """Handle a tool invocation."""
- try:
- if name == "waf_capabilities":
- return {
- "content": [
- {
- "type": "text",
- "text": json.dumps({
- "capabilities": self.waf.capabilities,
- "status": "operational"
- }, indent=2)
- }
- ]
- }
-
- elif name == "waf_analyze":
- log_file = arguments.get("log_file")
- zone_id = arguments.get("zone_id")
-
- if log_file:
- result = self.analyzer.analyze_log_file(log_file)
- else:
- result = {
- "message": "No log file provided. Use zone_id for live analysis.",
- "capabilities": self.waf.capabilities
- }
-
- return {
- "content": [
- {"type": "text", "text": json.dumps(result, indent=2, default=str)}
- ]
- }
-
- elif name == "waf_assess":
- zone_id = arguments.get("zone_id")
- # full_assessment uses workspace paths, not zone_id
- assessment = self.waf.full_assessment(
- include_threat_intel=True
- )
- # Build result from ThreatAssessment dataclass
- result = {
- "zone_id": zone_id,
- "risk_score": assessment.risk_score,
- "risk_level": assessment.risk_level,
- "classification_summary": assessment.classification_summary,
- "recommended_actions": assessment.recommended_actions[:10], # Top 10
- "has_analysis": assessment.analysis_result is not None,
- "has_threat_intel": assessment.threat_report is not None,
- "generated_at": str(assessment.generated_at)
- }
-
- return {
- "content": [
- {"type": "text", "text": json.dumps(result, indent=2, default=str)}
- ]
- }
-
- elif name == "waf_generate_rules":
- zone_id = arguments.get("zone_id")
- min_confidence = arguments.get("min_confidence", 0.7)
-
- # Generate proposals (doesn't use zone_id directly)
- proposals = self.waf.generate_gitops_proposals(
- max_proposals=5
- )
-
- result = {
- "zone_id": zone_id,
- "min_confidence": min_confidence,
- "proposals_count": len(proposals),
- "proposals": proposals
- }
-
- return {
- "content": [
- {"type": "text", "text": json.dumps(result, indent=2, default=str) if proposals else "No rules generated (no threat data available)"}
- ]
- }
-
- else:
- return {
- "content": [
- {"type": "text", "text": f"Unknown tool: {name}"}
- ],
- "isError": True
- }
-
- except Exception as e:
- return {
- "content": [
- {"type": "text", "text": f"Error: {str(e)}"}
- ],
- "isError": True
- }
-
- def run(self):
- """Run the MCP server (stdio mode)."""
- # Send server info
- server_info = {
- "jsonrpc": "2.0",
- "method": "initialized",
- "params": {
- "serverInfo": {
- "name": "waf-intelligence",
- "version": "1.0.0"
- },
- "capabilities": self.get_capabilities()
- }
- }
-
- # Main loop - read JSON-RPC messages from stdin
- for line in sys.stdin:
- try:
- message = json.loads(line.strip())
-
- if message.get("method") == "initialize":
- response = {
- "jsonrpc": "2.0",
- "id": message.get("id"),
- "result": {
- "protocolVersion": "2024-11-05",
- "serverInfo": {
- "name": "waf-intelligence",
- "version": "1.0.0"
- },
- "capabilities": {
- "tools": {}
- }
- }
- }
- print(json.dumps(response), flush=True)
-
- elif message.get("method") == "tools/list":
- response = {
- "jsonrpc": "2.0",
- "id": message.get("id"),
- "result": self.get_capabilities()
- }
- print(json.dumps(response), flush=True)
-
- elif message.get("method") == "tools/call":
- params = message.get("params", {})
- tool_name = params.get("name")
- tool_args = params.get("arguments", {})
-
- # Layer 0: pre-boot Shadow Eval gate before handling tool calls.
- routing_action, shadow = layer0_entry(_shadow_query_repr(tool_name, tool_args))
- if routing_action != "HANDOFF_TO_LAYER1":
- response = _layer0_mcp_response(routing_action, shadow, message.get("id"))
- print(json.dumps(response), flush=True)
- continue
-
- result = self.handle_tool_call(tool_name, tool_args)
-
- response = {
- "jsonrpc": "2.0",
- "id": message.get("id"),
- "result": result
- }
- print(json.dumps(response), flush=True)
-
- elif message.get("method") == "notifications/initialized":
- # Client acknowledged initialization
- pass
-
- else:
- # Unknown method
- response = {
- "jsonrpc": "2.0",
- "id": message.get("id"),
- "error": {
- "code": -32601,
- "message": f"Method not found: {message.get('method')}"
- }
- }
- print(json.dumps(response), flush=True)
-
- except json.JSONDecodeError:
- continue
- except Exception as e:
- error_response = {
- "jsonrpc": "2.0",
- "id": None,
- "error": {
- "code": -32603,
- "message": str(e)
- }
- }
- print(json.dumps(error_response), flush=True)
-
+from .mcp_server import main
if __name__ == "__main__":
- server = WAFIntelligenceMCPServer()
- server.run()
+ main()
-
-def _shadow_query_repr(tool_name: str, tool_args: dict) -> str:
- """Build a textual representation of the tool call for Layer 0 classification."""
- try:
- return f"{tool_name}: {json.dumps(tool_args, sort_keys=True)}"
- except TypeError:
- return f"{tool_name}: {str(tool_args)}"
-
-
-def _layer0_mcp_response(routing_action: str, shadow: ShadowEvalResult, msg_id: Any) -> dict:
- """
- Map Layer 0 outcomes to MCP responses.
- Catastrophic/forbidden/ambiguous short-circuit with minimal disclosure.
- """
- base = {"jsonrpc": "2.0", "id": msg_id}
-
- if routing_action == "FAIL_CLOSED":
- base["error"] = {"code": -32000, "message": "Layer 0: cannot comply with this request."}
- return base
-
- if routing_action == "HANDOFF_TO_GUARDRAILS":
- reason = shadow.reason or "governance_violation"
- base["error"] = {
- "code": -32001,
- "message": f"Layer 0: governance violation detected ({reason}).",
- }
- return base
-
- if routing_action == "PROMPT_FOR_CLARIFICATION":
- base["error"] = {
- "code": -32002,
- "message": "Layer 0: request is ambiguous. Please clarify and retry.",
- }
- return base
-
- base["error"] = {"code": -32099, "message": "Layer 0: unrecognized routing action; refusing."}
- return base
diff --git a/opencode.jsonc b/opencode.jsonc
index faac715..03ae9ff 100644
--- a/opencode.jsonc
+++ b/opencode.jsonc
@@ -2,92 +2,92 @@
"$schema": "https://opencode.ai/config.json",
"mcp": {
// Popular open-source MCP servers
-
+
// File system operations
"filesystem": {
"type": "local",
- "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem"],
+ "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "."],
"environment": {
- "HOME": "{env:HOME}"
+ "HOME": "{env:HOME}",
},
- "enabled": true
+ "enabled": true,
},
-
+
// Git operations
"git": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-git"],
- "enabled": true
+ "enabled": false,
},
-
+
// GitHub integration
"github": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-github"],
"environment": {
- "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_TOKEN}"
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "{env:GITHUB_TOKEN}",
},
- "enabled": true
+ "enabled": true,
},
-
+
// Postgres database
"postgres": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-postgres"],
"environment": {
- "DATABASE_URL": "{env:DATABASE_URL}"
+ "DATABASE_URL": "{env:DATABASE_URL}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// SQLite database
"sqlite": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-sqlite"],
- "enabled": false
+ "enabled": false,
},
-
+
// Docker integration
"docker": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-docker"],
- "enabled": false
+ "enabled": false,
},
-
+
// Web scraping
"web-scraper": {
"type": "local",
"command": ["npx", "-y", "web-scraper-mcp"],
- "enabled": false
+ "enabled": false,
},
-
+
// Google Maps integration
"googlemaps": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-google-maps"],
"environment": {
- "GOOGLE_MAPS_API_KEY": "{env:GOOGLE_MAPS_API_KEY}"
+ "GOOGLE_MAPS_API_KEY": "{env:GOOGLE_MAPS_API_KEY}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// Slack integration
"slack": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-slack"],
"environment": {
- "SLACK_BOT_TOKEN": "{env:SLACK_BOT_TOKEN}"
+ "SLACK_BOT_TOKEN": "{env:SLACK_BOT_TOKEN}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// Memory/knowledge base
"memory": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-memory"],
- "enabled": false
+ "enabled": false,
},
-
+
// AWS integration
"aws": {
"type": "local",
@@ -95,73 +95,80 @@
"environment": {
"AWS_ACCESS_KEY_ID": "{env:AWS_ACCESS_KEY_ID}",
"AWS_SECRET_ACCESS_KEY": "{env:AWS_SECRET_ACCESS_KEY}",
- "AWS_REGION": "{env:AWS_REGION}"
+ "AWS_REGION": "{env:AWS_REGION}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// Linear integration
"linear": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-linear"],
"environment": {
- "LINEAR_API_KEY": "{env:LINEAR_API_KEY}"
+ "LINEAR_API_KEY": "{env:LINEAR_API_KEY}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// Knowledge search via Context7
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
- "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
+ "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}",
},
- "enabled": false
+ "enabled": false,
},
-
+
// GitHub code search via Grep
"gh_grep": {
"type": "remote",
"url": "https://mcp.grep.app",
- "enabled": true
+ "enabled": true,
},
// WAF intelligence orchestrator
"waf_intel": {
"type": "local",
- "command": ["python3", "waf_intel_mcp.py"],
+ "command": ["/bin/bash", "/Users/sovereign/work-core/.secret/mcp/waf_intelligence.sh"],
"enabled": true,
- "timeout": 300000
+ "timeout": 300000,
},
-
+
// GitLab integration
"gitlab": {
"type": "local",
- "command": ["npx", "-y", "@modelcontextprotocol/server-gitlab"],
- "environment": {
- "GITLAB_TOKEN": "{env:GITLAB_TOKEN}",
- "GITLAB_URL": "{env:GITLAB_URL:https://gitlab.com}"
- },
- "enabled": false
+ "command": ["/opt/homebrew/bin/python3", "-u", "/Users/sovereign/work-core/.secret/gitlab_mcp_opencode_proxy.py"],
+ "enabled": true,
},
-
+
// Cloudflare API integration
"cloudflare": {
"type": "local",
- "command": ["npx", "-y", "@modelcontextprotocol/server-cloudflare"],
+ "command": ["/bin/bash", "/Users/sovereign/work-core/.secret/mcp_cloudflare_safe.sh"],
"environment": {
"CLOUDFLARE_API_TOKEN": "{env:CLOUDFLARE_API_TOKEN}",
- "CLOUDFLARE_ACCOUNT_ID": "{env:CLOUDFLARE_ACCOUNT_ID}"
+ "CLOUDFLARE_ACCOUNT_ID": "{env:CLOUDFLARE_ACCOUNT_ID}",
},
- "enabled": false
+ "enabled": true,
},
-
+
+ // Akash docs + SDL helpers (read-only; no wallet/key handling)
+ "akash_docs": {
+ "type": "local",
+ "command": ["python3", "-m", "cloudflare.mcp.akash_docs"],
+ "environment": {
+ "PYTHONPATH": "/Users/sovereign/work-core"
+ },
+ "enabled": false,
+ "timeout": 300000,
+ },
+
// Test server (remove in production)
"test_everything": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
- "enabled": false
- }
- }
+ "enabled": false,
+ },
+ },
}
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..cb2b4f9
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1 @@
+pytest>=8.0.0,<9
diff --git a/scripts/deploy_infrastructure.sh b/scripts/deploy_infrastructure.sh
new file mode 100644
index 0000000..bd14079
--- /dev/null
+++ b/scripts/deploy_infrastructure.sh
@@ -0,0 +1,308 @@
+#!/bin/bash
+
+# Cloudflare Infrastructure Deployment Automation
+# Automated Terraform deployment with safety checks and rollback capabilities
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Configuration
+TERRAFORM_DIR="terraform"
+BACKUP_DIR="terraform_backups"
+STATE_FILE="terraform.tfstate"
+PLAN_FILE="deployment_plan.tfplan"
+LOG_FILE="deployment_$(date +%Y%m%d_%H%M%S).log"
+
+# Function to log messages
+log() {
+ echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE"
+}
+
+# Function to log success
+success() {
+ echo -e "${GREEN}✅ $1${NC}" | tee -a "$LOG_FILE"
+}
+
+# Function to log warning
+warning() {
+ echo -e "${YELLOW}⚠️ $1${NC}" | tee -a "$LOG_FILE"
+}
+
+# Function to log error
+error() {
+ echo -e "${RED}❌ $1${NC}" | tee -a "$LOG_FILE"
+ exit 1
+}
+
+# Function to check prerequisites
+check_prerequisites() {
+ log "Checking prerequisites..."
+
+ # Check if .env file exists
+ if [[ ! -f "../.env" ]]; then
+ error "Missing .env file. Run setup_credentials.sh first."
+ fi
+
+ # Source environment variables
+ source "../.env"
+
+ # Check required variables
+ if [[ -z "$CLOUDFLARE_API_TOKEN" ]]; then
+ error "CLOUDFLARE_API_TOKEN not set in .env"
+ fi
+
+ if [[ -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then
+ error "CLOUDFLARE_ACCOUNT_ID not set in .env"
+ fi
+
+ # Check Terraform installation
+ if ! command -v terraform &> /dev/null; then
+ error "Terraform not found. Please install Terraform first."
+ fi
+
+ # Check Terraform version
+ TF_VERSION=$(terraform version | head -n1 | awk '{print $2}' | sed 's/v//')
+ log "Terraform version: $TF_VERSION"
+
+ success "Prerequisites check passed"
+}
+
+# Function to backup current state
+backup_state() {
+ log "Creating backup of current state..."
+
+ # Create backup directory
+ mkdir -p "$BACKUP_DIR"
+
+ # Backup state file if it exists
+ if [[ -f "$STATE_FILE" ]]; then
+ BACKUP_NAME="${BACKUP_DIR}/state_backup_$(date +%Y%m%d_%H%M%S).tfstate"
+ cp "$STATE_FILE" "$BACKUP_NAME"
+ success "State backed up to: $BACKUP_NAME"
+ else
+ warning "No existing state file found"
+ fi
+
+ # Backup terraform.tfvars
+ if [[ -f "terraform.tfvars" ]]; then
+ cp "terraform.tfvars" "${BACKUP_DIR}/terraform.tfvars.backup"
+ fi
+}
+
+# Function to prepare terraform.tfvars
+prepare_config() {
+ log "Preparing Terraform configuration..."
+
+ # Update terraform.tfvars with actual credentials
+ cat > terraform.tfvars << EOF
+cloudflare_api_token = "$CLOUDFLARE_API_TOKEN"
+cloudflare_account_id = "$CLOUDFLARE_ACCOUNT_ID"
+cloudflare_account_name = "" # Use account_id from .env
+EOF
+
+ # Add optional Zone ID if set
+ if [[ -n "$CLOUDFLARE_ZONE_ID" ]]; then
+ echo "cloudflare_zone_id = \"$CLOUDFLARE_ZONE_ID\"" >> terraform.tfvars
+ fi
+
+ success "Configuration prepared"
+}
+
+# Function to initialize Terraform
+init_terraform() {
+ log "Initializing Terraform..."
+
+ if terraform init -upgrade; then
+ success "Terraform initialized successfully"
+ else
+ error "Terraform initialization failed"
+ fi
+}
+
+# Function to validate Terraform configuration
+validate_config() {
+ log "Validating Terraform configuration..."
+
+ if terraform validate; then
+ success "Configuration validation passed"
+ else
+ error "Configuration validation failed"
+ fi
+}
+
+# Function to create deployment plan
+create_plan() {
+ log "Creating deployment plan..."
+
+ if terraform plan -out="$PLAN_FILE" -detailed-exitcode; then
+ case $? in
+ 0)
+ success "No changes needed"
+ return 0
+ ;;
+ 2)
+ success "Plan created successfully"
+ return 2
+ ;;
+ *)
+ error "Plan creation failed"
+ ;;
+ esac
+ else
+ error "Plan creation failed"
+ fi
+}
+
+# Function to show plan summary
+show_plan_summary() {
+ log "Plan Summary:"
+ terraform show -json "$PLAN_FILE" | jq -r '
+ .resource_changes[] |
+ select(.change.actions != ["no-op"]) |
+ "\(.change.actions | join(",")) \(.type).\(.name)"
+ ' | sort | tee -a "$LOG_FILE"
+}
+
+# Function to confirm deployment
+confirm_deployment() {
+ echo
+ echo "=================================================="
+ echo "🚀 DEPLOYMENT CONFIRMATION"
+ echo "=================================================="
+ echo
+ echo "The following changes will be applied:"
+ show_plan_summary
+ echo
+ echo "Log file: $LOG_FILE"
+ echo "Backup directory: $BACKUP_DIR"
+ echo
+ read -p "Do you want to proceed with deployment? (y/n): " -n 1 -r
+ echo
+
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ log "Deployment cancelled by user"
+ exit 0
+ fi
+}
+
+# Function to apply deployment
+apply_deployment() {
+ log "Applying deployment..."
+
+ if terraform apply "$PLAN_FILE"; then
+ success "Deployment applied successfully"
+ else
+ error "Deployment failed"
+ fi
+}
+
+# Function to verify deployment
+verify_deployment() {
+ log "Verifying deployment..."
+
+ # Check if resources were created successfully
+ OUTPUTS=$(terraform output -json)
+
+ if [[ -n "$OUTPUTS" ]]; then
+ success "Deployment verification passed"
+ echo "Outputs:"
+ terraform output
+ else
+ warning "No outputs generated - manual verification required"
+ fi
+}
+
+# Function to cleanup temporary files
+cleanup() {
+ log "Cleaning up temporary files..."
+
+ if [[ -f "$PLAN_FILE" ]]; then
+ rm "$PLAN_FILE"
+ success "Plan file removed"
+ fi
+}
+
+# Function to show deployment summary
+deployment_summary() {
+ echo
+ echo "=================================================="
+ echo "🎉 DEPLOYMENT SUMMARY"
+ echo "=================================================="
+ echo
+ echo "✅ Infrastructure deployed successfully"
+ echo "📋 Log file: $LOG_FILE"
+ echo "💾 Backups: $BACKUP_DIR"
+ echo "🌐 Resources deployed:"
+ terraform state list
+ echo
+ echo "Next steps:"
+ echo "1. Check Cloudflare dashboard for deployed resources"
+ echo "2. Test DNS resolution for your domains"
+ echo "3. Verify WAF rules are active"
+ echo "4. Test tunnel connectivity"
+ echo
+}
+
+# Function to handle rollback
+rollback() {
+ error "Deployment failed - rolling back..."
+
+ # Check if we have a backup
+ LATEST_BACKUP=$(ls -t "${BACKUP_DIR}/state_backup_*.tfstate" 2>/dev/null | head -n1)
+
+ if [[ -n "$LATEST_BACKUP" ]]; then
+ log "Restoring from backup: $LATEST_BACKUP"
+ cp "$LATEST_BACKUP" "$STATE_FILE"
+ warning "State restored from backup. Manual verification required."
+ else
+ error "No backup available for rollback"
+ fi
+}
+
+# Main deployment function
+main() {
+ echo "🚀 Cloudflare Infrastructure Deployment"
+ echo "=================================================="
+ echo
+
+ # Change to Terraform directory
+ cd "$TERRAFORM_DIR" || error "Terraform directory not found"
+
+ # Set trap for cleanup on exit
+ trap cleanup EXIT
+
+ # Execute deployment steps
+ check_prerequisites
+ backup_state
+ prepare_config
+ init_terraform
+ validate_config
+
+ # Create plan and check if changes are needed
+ if create_plan; then
+ case $? in
+ 0)
+ success "No changes needed - infrastructure is up to date"
+ exit 0
+ ;;
+ 2)
+ confirm_deployment
+ apply_deployment
+ verify_deployment
+ deployment_summary
+ ;;
+ esac
+ fi
+}
+
+# Handle errors
+trap 'rollback' ERR
+
+# Run main function
+main "$@"
\ No newline at end of file
diff --git a/scripts/incident_response_playbooks.py b/scripts/incident_response_playbooks.py
new file mode 100644
index 0000000..3be34f8
--- /dev/null
+++ b/scripts/incident_response_playbooks.py
@@ -0,0 +1,421 @@
+#!/usr/bin/env python3
+"""
+Cloudflare Incident Response Playbooks
+Standardized procedures for common infrastructure incidents
+"""
+
+from enum import Enum
+from typing import Dict, List, Optional
+from dataclasses import dataclass
+from datetime import datetime
+
+
+class IncidentSeverity(str, Enum):
+ """Incident severity levels"""
+
+ LOW = "low"
+ MEDIUM = "medium"
+ HIGH = "high"
+ CRITICAL = "critical"
+
+
+class IncidentType(str, Enum):
+ """Types of infrastructure incidents"""
+
+ DNS_OUTAGE = "dns_outage"
+ WAF_BYPASS = "waf_bypass"
+ TUNNEL_FAILURE = "tunnel_failure"
+ SECURITY_BREACH = "security_breach"
+ CONFIGURATION_ERROR = "configuration_error"
+ PERFORMANCE_DEGRADATION = "performance_degradation"
+
+
+@dataclass
+class IncidentResponse:
+ """Incident response procedure"""
+
+ incident_type: IncidentType
+ severity: IncidentSeverity
+ immediate_actions: List[str]
+ investigation_steps: List[str]
+ recovery_procedures: List[str]
+ prevention_measures: List[str]
+ escalation_path: List[str]
+ time_to_resolve: str
+
+
+class IncidentResponsePlaybook:
+ """Collection of incident response playbooks"""
+
+ def __init__(self):
+ self.playbooks = self._initialize_playbooks()
+
+ def _initialize_playbooks(self) -> Dict[IncidentType, IncidentResponse]:
+ """Initialize all incident response playbooks"""
+ return {
+ IncidentType.DNS_OUTAGE: IncidentResponse(
+ incident_type=IncidentType.DNS_OUTAGE,
+ severity=IncidentSeverity.HIGH,
+ immediate_actions=[
+ "Verify DNS resolution using external tools (dig, nslookup)",
+ "Check Cloudflare DNS dashboard for zone status",
+ "Review recent DNS changes in version control",
+ "Verify origin server connectivity",
+ "Check Cloudflare status page for service issues",
+ ],
+ investigation_steps=[
+ "Examine DNS record changes in Git history",
+ "Check Terraform state for unexpected modifications",
+ "Review Cloudflare audit logs for recent changes",
+ "Verify DNS propagation using multiple geographic locations",
+ "Check for DNSSEC configuration issues",
+ ],
+ recovery_procedures=[
+ "Rollback recent DNS changes using Terraform",
+ "Manually restore critical DNS records if needed",
+ "Update TTL values for faster propagation",
+ "Contact Cloudflare support if service-related",
+ "Implement traffic rerouting if necessary",
+ ],
+ prevention_measures=[
+ "Implement DNS change approval workflows",
+ "Use Terraform plan/apply with peer review",
+ "Monitor DNS resolution from multiple locations",
+ "Implement automated DNS health checks",
+ "Maintain backup DNS configurations",
+ ],
+ escalation_path=[
+ "Primary DNS Administrator",
+ "Infrastructure Team Lead",
+ "Cloudflare Support",
+ "Security Team",
+ ],
+ time_to_resolve="1-4 hours",
+ ),
+ IncidentType.WAF_BYPASS: IncidentResponse(
+ incident_type=IncidentType.WAF_BYPASS,
+ severity=IncidentSeverity.CRITICAL,
+ immediate_actions=[
+ "Immediately review WAF event logs for suspicious activity",
+ "Check for recent WAF rule modifications",
+ "Verify WAF rule package status and mode",
+ "Temporarily block suspicious IP addresses",
+ "Enable challenge mode for suspicious traffic patterns",
+ ],
+ investigation_steps=[
+ "Analyze WAF rule changes in version control",
+ "Review Cloudflare firewall event logs",
+ "Check for anomalous traffic patterns",
+ "Verify WAF rule effectiveness using test payloads",
+ "Examine rate limiting and threat score thresholds",
+ ],
+ recovery_procedures=[
+ "Rollback WAF rule changes to known good state",
+ "Implement emergency WAF rules to block attack patterns",
+ "Update threat intelligence feeds",
+ "Increase security level for affected zones",
+ "Deploy additional security measures (Bot Fight Mode, etc.)",
+ ],
+ prevention_measures=[
+ "Implement WAF change approval workflows",
+ "Regular security testing of WAF rules",
+ "Monitor WAF event logs for anomalies",
+ "Implement automated WAF rule validation",
+ "Regular security awareness training",
+ ],
+ escalation_path=[
+ "Security Incident Response Team",
+ "WAF Administrator",
+ "Infrastructure Security Lead",
+ "CISO/Management",
+ ],
+ time_to_resolve="2-6 hours",
+ ),
+ IncidentType.TUNNEL_FAILURE: IncidentResponse(
+ incident_type=IncidentType.TUNNEL_FAILURE,
+ severity=IncidentSeverity.MEDIUM,
+ immediate_actions=[
+ "Check Cloudflare Tunnel status and connectivity",
+ "Verify origin server availability and configuration",
+ "Check tunnel connector logs for errors",
+ "Restart tunnel connector service if needed",
+ "Verify DNS records point to correct tunnel endpoints",
+ ],
+ investigation_steps=[
+ "Review recent tunnel configuration changes",
+ "Check network connectivity between connector and Cloudflare",
+ "Examine tunnel connector resource usage",
+ "Verify certificate validity and renewal status",
+ "Check for firewall/network policy changes",
+ ],
+ recovery_procedures=[
+ "Restart tunnel connector with updated configuration",
+ "Rollback recent tunnel configuration changes",
+ "Recreate tunnel connector if necessary",
+ "Update DNS records to alternative endpoints",
+ "Implement traffic failover mechanisms",
+ ],
+ prevention_measures=[
+ "Implement tunnel health monitoring",
+ "Use redundant tunnel configurations",
+ "Regular tunnel connector updates and maintenance",
+ "Monitor certificate expiration dates",
+ "Implement automated tunnel failover",
+ ],
+ escalation_path=[
+ "Network Administrator",
+ "Infrastructure Team",
+ "Cloudflare Support",
+ "Security Team",
+ ],
+ time_to_resolve="1-3 hours",
+ ),
+ IncidentType.SECURITY_BREACH: IncidentResponse(
+ incident_type=IncidentType.SECURITY_BREACH,
+ severity=IncidentSeverity.CRITICAL,
+ immediate_actions=[
+ "Isolate affected systems and services immediately",
+ "Preserve logs and evidence for forensic analysis",
+ "Change all relevant credentials and API tokens",
+ "Notify security incident response team",
+ "Implement emergency security controls",
+ ],
+ investigation_steps=[
+ "Conduct forensic analysis of compromised systems",
+ "Review Cloudflare audit logs for unauthorized access",
+ "Check for API token misuse or unauthorized changes",
+ "Examine DNS/WAF/Tunnel configuration changes",
+ "Coordinate with legal and compliance teams",
+ ],
+ recovery_procedures=[
+ "Rotate all Cloudflare API tokens and credentials",
+ "Restore configurations from verified backups",
+ "Implement enhanced security monitoring",
+ "Conduct post-incident security assessment",
+ "Update incident response procedures based on lessons learned",
+ ],
+ prevention_measures=[
+ "Implement multi-factor authentication",
+ "Regular security audits and penetration testing",
+ "Monitor for suspicious API activity",
+ "Implement least privilege access controls",
+ "Regular security awareness training",
+ ],
+ escalation_path=[
+ "Security Incident Response Team",
+ "CISO/Management",
+ "Legal Department",
+ "External Security Consultants",
+ ],
+ time_to_resolve="4-24 hours",
+ ),
+ IncidentType.CONFIGURATION_ERROR: IncidentResponse(
+ incident_type=IncidentType.CONFIGURATION_ERROR,
+ severity=IncidentSeverity.MEDIUM,
+ immediate_actions=[
+ "Identify the specific configuration error",
+ "Assess impact on services and users",
+ "Check version control for recent changes",
+ "Verify Terraform plan output for unexpected changes",
+ "Communicate status to stakeholders",
+ ],
+ investigation_steps=[
+ "Review Git commit history for configuration changes",
+ "Examine Terraform state differences",
+ "Check Cloudflare configuration against documented standards",
+ "Verify configuration consistency across environments",
+ "Identify root cause of configuration error",
+ ],
+ recovery_procedures=[
+ "Rollback configuration using Terraform",
+ "Apply corrected configuration changes",
+ "Verify service restoration and functionality",
+ "Update configuration documentation",
+ "Implement configuration validation checks",
+ ],
+ prevention_measures=[
+ "Implement configuration change approval workflows",
+ "Use infrastructure as code with peer review",
+ "Implement automated configuration validation",
+ "Regular configuration audits",
+ "Maintain configuration documentation",
+ ],
+ escalation_path=[
+ "Configuration Administrator",
+ "Infrastructure Team Lead",
+ "Quality Assurance Team",
+ "Management",
+ ],
+ time_to_resolve="1-4 hours",
+ ),
+ IncidentType.PERFORMANCE_DEGRADATION: IncidentResponse(
+ incident_type=IncidentType.PERFORMANCE_DEGRADATION,
+ severity=IncidentSeverity.LOW,
+ immediate_actions=[
+ "Monitor performance metrics and identify bottlenecks",
+ "Check Cloudflare analytics for traffic patterns",
+ "Verify origin server performance and resource usage",
+ "Review recent configuration changes",
+ "Implement temporary performance optimizations",
+ ],
+ investigation_steps=[
+ "Analyze performance metrics over time",
+ "Check for DDoS attacks or abnormal traffic patterns",
+ "Review caching configuration and hit rates",
+ "Examine origin server response times",
+ "Identify specific performance bottlenecks",
+ ],
+ recovery_procedures=[
+ "Optimize caching configuration",
+ "Adjust performance settings (Polish, Mirage, etc.)",
+ "Implement rate limiting if under attack",
+ "Scale origin server resources if needed",
+ "Update CDN configuration for better performance",
+ ],
+ prevention_measures=[
+ "Implement performance monitoring and alerting",
+ "Regular performance testing and optimization",
+ "Capacity planning and resource forecasting",
+ "Implement automated scaling mechanisms",
+ "Regular performance reviews and optimizations",
+ ],
+ escalation_path=[
+ "Performance Monitoring Team",
+ "Infrastructure Team",
+ "Application Development Team",
+ "Management",
+ ],
+ time_to_resolve="2-8 hours",
+ ),
+ }
+
+ def get_playbook(self, incident_type: IncidentType) -> Optional[IncidentResponse]:
+ """Get the playbook for a specific incident type"""
+ return self.playbooks.get(incident_type)
+
+ def list_playbooks(self) -> List[IncidentType]:
+ """List all available playbooks"""
+ return list(self.playbooks.keys())
+
+ def execute_playbook(
+ self, incident_type: IncidentType, custom_context: Optional[Dict] = None
+ ) -> Dict:
+ """Execute a specific incident response playbook"""
+ playbook = self.get_playbook(incident_type)
+
+ if not playbook:
+ return {"error": f"No playbook found for incident type: {incident_type}"}
+
+ execution_log = {
+ "incident_type": incident_type.value,
+ "severity": playbook.severity.value,
+ "start_time": datetime.now().isoformat(),
+ "steps_completed": [],
+ "custom_context": custom_context or {},
+ }
+
+ # Simulate execution (in real implementation, this would trigger actual actions)
+ execution_log["steps_completed"].extend(
+ [
+ f"Initiated {incident_type.value} response procedure",
+ f"Severity level: {playbook.severity.value}",
+ "Notified escalation path contacts",
+ ]
+ )
+
+ execution_log["estimated_resolution_time"] = playbook.time_to_resolve
+ execution_log["completion_status"] = "in_progress"
+
+ return execution_log
+
+
+def main():
+ """Command-line interface for incident response playbooks"""
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description="Cloudflare Incident Response Playbooks"
+ )
+ parser.add_argument(
+ "action", choices=["list", "show", "execute"], help="Action to perform"
+ )
+ parser.add_argument(
+ "--type", choices=[t.value for t in IncidentType], help="Incident type"
+ )
+
+ args = parser.parse_args()
+
+ playbook_manager = IncidentResponsePlaybook()
+
+ if args.action == "list":
+ print("📋 Available Incident Response Playbooks:")
+ print("-" * 50)
+ for incident_type in playbook_manager.list_playbooks():
+ playbook = playbook_manager.get_playbook(incident_type)
+ if not playbook:
+ continue
+
+ print(f"🔸 {incident_type.value}")
+ print(f" Severity: {playbook.severity.value}")
+ print(f" Resolution Time: {playbook.time_to_resolve}")
+ print()
+
+ elif args.action == "show":
+ if not args.type:
+ print("❌ Error: --type argument required")
+ return
+
+ try:
+ incident_type = IncidentType(args.type)
+ except ValueError:
+ print(f"❌ Error: Invalid incident type: {args.type}")
+ return
+
+ playbook = playbook_manager.get_playbook(incident_type)
+ if not playbook:
+ print(f"❌ Error: No playbook found for {args.type}")
+ return
+
+ print(f"🔍 Incident Response Playbook: {incident_type.value}")
+ print("=" * 60)
+ print(f"Severity: {playbook.severity.value}")
+ print(f"Estimated Resolution: {playbook.time_to_resolve}")
+
+ print("\n🚨 Immediate Actions:")
+ for i, action in enumerate(playbook.immediate_actions, 1):
+ print(f" {i}. {action}")
+
+ print("\n🔍 Investigation Steps:")
+ for i, step in enumerate(playbook.investigation_steps, 1):
+ print(f" {i}. {step}")
+
+ print("\n🔄 Recovery Procedures:")
+ for i, procedure in enumerate(playbook.recovery_procedures, 1):
+ print(f" {i}. {procedure}")
+
+ print("\n🛡️ Prevention Measures:")
+ for i, measure in enumerate(playbook.prevention_measures, 1):
+ print(f" {i}. {measure}")
+
+ print("\n📞 Escalation Path:")
+ for i, contact in enumerate(playbook.escalation_path, 1):
+ print(f" {i}. {contact}")
+
+ elif args.action == "execute":
+ if not args.type:
+ print("❌ Error: --type argument required")
+ return
+
+ try:
+ incident_type = IncidentType(args.type)
+ except ValueError:
+ print(f"❌ Error: Invalid incident type: {args.type}")
+ return
+
+ result = playbook_manager.execute_playbook(incident_type)
+ print(f"🚀 Executing {incident_type.value} Incident Response")
+ print(f"📊 Result: {result}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/monitoring_dashboard.py b/scripts/monitoring_dashboard.py
new file mode 100644
index 0000000..98d95ee
--- /dev/null
+++ b/scripts/monitoring_dashboard.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+"""
+Cloudflare Infrastructure Monitoring Dashboard
+Provides real-time monitoring of Cloudflare resources and services
+"""
+
+import os
+import json
+import time
+import requests
+from datetime import datetime, timedelta
+from typing import Dict, List, Any
+
+
+class CloudflareMonitor:
+ def __init__(self):
+ self.base_url = "https://api.cloudflare.com/client/v4"
+ self.headers = {
+ "Authorization": f"Bearer {os.getenv('CLOUDFLARE_API_TOKEN')}",
+ "Content-Type": "application/json",
+ }
+ self.account_id = os.getenv("CLOUDFLARE_ACCOUNT_ID")
+
+ if not self.account_id or not os.getenv("CLOUDFLARE_API_TOKEN"):
+ raise ValueError("Missing Cloudflare credentials in environment")
+
+ def make_request(self, endpoint: str) -> Dict[str, Any]:
+ """Make API request with error handling"""
+ url = f"{self.base_url}{endpoint}"
+ try:
+ response = requests.get(url, headers=self.headers, timeout=10)
+ response.raise_for_status()
+ return response.json()
+ except requests.RequestException as e:
+ return {"success": False, "errors": [str(e)]}
+
+ def get_account_info(self) -> Dict[str, Any]:
+ """Get account information"""
+ return self.make_request(f"/accounts/{self.account_id}")
+
+ def get_zones(self) -> List[Dict[str, Any]]:
+ """Get all zones"""
+ result = self.make_request(f"/zones?account.id={self.account_id}&per_page=50")
+ return result.get("result", []) if result.get("success") else []
+
+ def get_zone_analytics(self, zone_id: str) -> Dict[str, Any]:
+ """Get zone analytics for the last hour"""
+ since = (datetime.now() - timedelta(hours=1)).isoformat()
+ return self.make_request(f"/zones/{zone_id}/analytics/dashboard?since={since}")
+
+ def get_waf_rules(self, zone_id: str) -> List[Dict[str, Any]]:
+ """Get WAF rules for a zone"""
+ result = self.make_request(f"/zones/{zone_id}/firewall/waf/packages")
+ if result.get("success"):
+ packages = result.get("result", [])
+ rules = []
+ for package in packages:
+ rules_result = self.make_request(
+ f"/zones/{zone_id}/firewall/waf/packages/{package['id']}/rules"
+ )
+ if rules_result.get("success"):
+ rules.extend(rules_result.get("result", []))
+ return rules
+ return []
+
+ def get_tunnels(self) -> List[Dict[str, Any]]:
+ """Get Cloudflare Tunnels"""
+ result = self.make_request(f"/accounts/{self.account_id}/cfd_tunnel")
+ return result.get("result", []) if result.get("success") else []
+
+ def get_dns_records(self, zone_id: str) -> List[Dict[str, Any]]:
+ """Get DNS records for a zone"""
+ result = self.make_request(f"/zones/{zone_id}/dns_records?per_page=100")
+ return result.get("result", []) if result.get("success") else []
+
+ def get_health_status(self) -> Dict[str, Any]:
+ """Get overall health status"""
+ status = "healthy"
+ issues = []
+
+ # Check zones
+ zones = self.get_zones()
+ if not zones:
+ issues.append("No zones found")
+ status = "warning"
+
+ # Check account access
+ account_info = self.get_account_info()
+ if not account_info.get("success"):
+ issues.append("Account access failed")
+ status = "critical"
+
+ return {"status": status, "issues": issues}
+
+
+def format_table(data: List[Dict[str, Any]], headers: List[str]) -> str:
+ """Format data as a table"""
+ if not data:
+ return "No data available"
+
+ # Calculate column widths
+ col_widths = [len(header) for header in headers]
+ for row in data:
+ for i, header in enumerate(headers):
+ value = str(row.get(header, ""))
+ col_widths[i] = max(col_widths[i], len(value))
+
+ # Create header row
+ header_row = " | ".join(
+ header.ljust(col_widths[i]) for i, header in enumerate(headers)
+ )
+ separator = "-" * len(header_row)
+
+ # Create data rows
+ rows = [header_row, separator]
+ for row in data:
+ row_data = [
+ str(row.get(header, "")).ljust(col_widths[i])
+ for i, header in enumerate(headers)
+ ]
+ rows.append(" | ".join(row_data))
+
+ return "\n".join(rows)
+
+
+def main():
+ print("🌐 Cloudflare Infrastructure Monitoring Dashboard")
+ print("=" * 60)
+ print(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+ print()
+
+ try:
+ monitor = CloudflareMonitor()
+
+ # Health check
+ print("🔍 Health Status")
+ print("-" * 30)
+ health = monitor.get_health_status()
+ status_emoji = {"healthy": "✅", "warning": "⚠️", "critical": "❌"}
+ print(
+ f"Status: {status_emoji.get(health['status'], '❓')} {health['status'].upper()}"
+ )
+ if health["issues"]:
+ for issue in health["issues"]:
+ print(f" - {issue}")
+ print()
+
+ # Account information
+ print("🏢 Account Information")
+ print("-" * 30)
+ account_info = monitor.get_account_info()
+ if account_info.get("success"):
+ account = account_info["result"]
+ print(f"Name: {account.get('name', 'N/A')}")
+ print(f"Type: {account.get('type', 'N/A')}")
+ print(f"Created: {account.get('created_on', 'N/A')}")
+ else:
+ print("Failed to retrieve account information")
+ print()
+
+ # Zones overview
+ print("🌐 Zones Overview")
+ print("-" * 30)
+ zones = monitor.get_zones()
+ zone_data = []
+ for zone in zones[:10]: # Limit to first 10 zones
+ zone_data.append(
+ {
+ "Name": zone.get("name", "N/A"),
+ "Status": zone.get("status", "N/A"),
+ "Plan": zone.get("plan", {}).get("name", "N/A"),
+ "Development": zone.get("development_mode", "N/A"),
+ }
+ )
+
+ print(format_table(zone_data, ["Name", "Status", "Plan", "Development"]))
+ print(f"Total zones: {len(zones)}")
+ print()
+
+ # DNS Records (for first zone)
+ dns_records = []
+ waf_rules = []
+
+ if zones:
+ first_zone = zones[0]
+ print("📋 DNS Records (First Zone)")
+ print("-" * 30)
+ dns_records = monitor.get_dns_records(first_zone["id"])
+ dns_data = []
+ for record in dns_records[:15]: # Limit to first 15 records
+ dns_data.append(
+ {
+ "Type": record.get("type", "N/A"),
+ "Name": record.get("name", "N/A"),
+ "Content": record.get("content", "N/A")[:40] + "..."
+ if len(record.get("content", "")) > 40
+ else record.get("content", "N/A"),
+ }
+ )
+
+ print(format_table(dns_data, ["Type", "Name", "Content"]))
+ print(f"Total DNS records: {len(dns_records)}")
+ print()
+
+ # Tunnels
+ print("🔗 Cloudflare Tunnels")
+ print("-" * 30)
+ tunnels = monitor.get_tunnels()
+ tunnel_data = []
+ for tunnel in tunnels:
+ tunnel_data.append(
+ {
+ "Name": tunnel.get("name", "N/A"),
+ "Status": tunnel.get("status", "N/A"),
+ "Connections": len(tunnel.get("connections", [])),
+ }
+ )
+
+ print(format_table(tunnel_data, ["Name", "Status", "Connections"]))
+ print(f"Total tunnels: {len(tunnels)}")
+ print()
+
+ # WAF Rules (for first zone)
+ if zones:
+ first_zone = zones[0]
+ print("🛡️ WAF Rules (First Zone)")
+ print("-" * 30)
+ waf_rules = monitor.get_waf_rules(first_zone["id"])
+ waf_data = []
+ for rule in waf_rules[:10]: # Limit to first 10 rules
+ waf_data.append(
+ {
+ "ID": rule.get("id", "N/A"),
+ "Description": rule.get("description", "N/A")[:50] + "..."
+ if len(rule.get("description", "")) > 50
+ else rule.get("description", "N/A"),
+ "Mode": rule.get("mode", "N/A"),
+ }
+ )
+
+ print(format_table(waf_data, ["ID", "Description", "Mode"]))
+ print(f"Total WAF rules: {len(waf_rules)}")
+ print()
+
+ # Summary
+ print("📊 Summary")
+ print("-" * 30)
+ print(f"Zones: {len(zones)}")
+ print(f"Tunnels: {len(tunnels)}")
+ if zones:
+ print(f"DNS Records (first zone): {len(dns_records)}")
+ print(f"WAF Rules (first zone): {len(waf_rules)}")
+
+ except Exception as e:
+ print(f"❌ Error: {e}")
+ print("Please ensure your Cloudflare credentials are properly configured.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/setup_credentials.py b/scripts/setup_credentials.py
new file mode 100644
index 0000000..947a0e8
--- /dev/null
+++ b/scripts/setup_credentials.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+"""
+Cloudflare Credential Setup Wizard
+Interactive script to guide users through configuring Cloudflare API credentials
+"""
+
+import os
+import sys
+import re
+from pathlib import Path
+
+
+def validate_api_token(token):
+ """Validate Cloudflare API token format"""
+ # Cloudflare API tokens are typically 40+ characters
+ return len(token.strip()) >= 40
+
+
+def validate_account_id(account_id):
+ """Validate Cloudflare Account ID format"""
+ # Account IDs are typically 32-character hex strings
+ return re.match(r"^[a-f0-9]{32}$", account_id.strip(), re.IGNORECASE) is not None
+
+
+def validate_zone_id(zone_id):
+ """Validate Cloudflare Zone ID format"""
+ # Zone IDs are also 32-character hex strings
+ return re.match(r"^[a-f0-9]{32}$", zone_id.strip(), re.IGNORECASE) is not None
+
+
+def get_input(prompt, validation_func=None, secret=False):
+ """Get validated user input"""
+ while True:
+ try:
+ if secret:
+ import getpass
+
+ value = getpass.getpass(prompt)
+ else:
+ value = input(prompt)
+
+ if validation_func:
+ if validation_func(value):
+ return value
+ else:
+ print("❌ Invalid format. Please try again.")
+ else:
+ return value
+ except KeyboardInterrupt:
+ print("\n\nSetup cancelled.")
+ sys.exit(1)
+
+
+def create_env_file(env_vars):
+ """Create or update .env file with credentials"""
+ env_path = Path(".env")
+
+ # Read existing .env if it exists
+ existing_vars = {}
+ if env_path.exists():
+ with open(env_path, "r") as f:
+ for line in f:
+ if line.strip() and not line.startswith("#") and "=" in line:
+ key, value = line.strip().split("=", 1)
+ existing_vars[key] = value
+
+ # Update with new values
+ existing_vars.update(env_vars)
+
+ # Write back
+ with open(env_path, "w") as f:
+ f.write("# OpenCode Environment Variables\n")
+ f.write("# Generated by setup_credentials.py\n")
+ f.write("# IMPORTANT: Never commit this file to git\n\n")
+
+ # Write Cloudflare section
+ f.write(
+ "# ============================================================================\n"
+ )
+ f.write("# CLOUDFLARE API CONFIGURATION\n")
+ f.write(
+ "# ============================================================================\n"
+ )
+
+ for key, value in env_vars.items():
+ f.write(f'{key}="{value}"\n')
+
+ f.write("\n")
+
+ # Preserve other sections if they exist
+ sections = {
+ "GITHUB": [k for k in existing_vars.keys() if k.startswith("GITHUB")],
+ "GITLAB": [k for k in existing_vars.keys() if k.startswith("GITLAB")],
+ "OTHER": [
+ k
+ for k in existing_vars.keys()
+ if k not in env_vars and not k.startswith(("GITHUB", "GITLAB"))
+ ],
+ }
+
+ for section_name, keys in sections.items():
+ if keys:
+ f.write(
+ f"# ============================================================================\n"
+ )
+ f.write(f"# {section_name} CONFIGURATION\n")
+ f.write(
+ f"# ============================================================================\n"
+ )
+ for key in keys:
+ f.write(f'{key}="{existing_vars[key]}"\n')
+ f.write("\n")
+
+ return env_path
+
+
+def main():
+ print("🚀 Cloudflare Credential Setup Wizard")
+ print("=" * 50)
+ print()
+
+ print("This wizard will help you configure your Cloudflare API credentials.")
+ print("You'll need:")
+ print("1. Cloudflare API Token (with appropriate permissions)")
+ print("2. Cloudflare Account ID")
+ print("3. Optional: Zone ID for specific domain management")
+ print()
+
+ # Check if we're in the right directory
+ current_dir = Path.cwd()
+ if "cloudflare" not in str(current_dir):
+ print("⚠️ Warning: This script should be run from the cloudflare directory")
+ print(f" Current directory: {current_dir}")
+ proceed = get_input("Continue anyway? (y/n): ")
+ if proceed.lower() != "y":
+ print(
+ "Please navigate to the cloudflare directory and run this script again."
+ )
+ return
+
+ # Collect credentials
+ print("\n🔐 Cloudflare API Configuration")
+ print("-" * 30)
+
+ # API Token
+ print("\n📋 Step 1: Cloudflare API Token")
+ print("Get your token from: https://dash.cloudflare.com/profile/api-tokens")
+ print("Required permissions: Zone:DNS:Edit, Zone:Page Rules:Edit, Account:Read")
+ api_token = get_input(
+ "API Token: ", validation_func=validate_api_token, secret=True
+ )
+
+ # Account ID
+ print("\n🏢 Step 2: Cloudflare Account ID")
+ print("Find your Account ID in the Cloudflare dashboard sidebar")
+ print("Format: 32-character hex string (e.g., 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p)")
+ account_id = get_input("Account ID: ", validation_func=validate_account_id)
+
+ # Zone ID (optional)
+ print("\n🌐 Step 3: Zone ID (Optional)")
+ print("If you want to manage a specific domain, provide its Zone ID")
+ print("Leave blank to skip")
+ zone_id = get_input(
+ "Zone ID (optional): ",
+ validation_func=lambda x: x.strip() == "" or validate_zone_id(x),
+ )
+
+ # Prepare environment variables
+ env_vars = {"CLOUDFLARE_API_TOKEN": api_token, "CLOUDFLARE_ACCOUNT_ID": account_id}
+
+ if zone_id.strip():
+ env_vars["CLOUDFLARE_ZONE_ID"] = zone_id
+
+ # Create .env file
+ print("\n💾 Saving credentials...")
+ env_path = create_env_file(env_vars)
+
+ # Set file permissions
+ env_path.chmod(0o600) # Only user read/write
+
+ print(f"✅ Credentials saved to: {env_path}")
+ print("🔒 File permissions set to 600 (owner read/write only)")
+
+ # Test configuration (basic validation only - no external dependencies)
+ print("\n🧪 Validating credentials...")
+
+ # Basic format validation
+ if validate_api_token(api_token) and validate_account_id(account_id):
+ print("✅ Credential formats are valid")
+ print("⚠️ Note: Full API connectivity test requires 'requests' module")
+ print(" Install with: pip install requests")
+ else:
+ print("❌ Credential validation failed")
+ print(" Please check your inputs and try again")
+
+ # Final instructions
+ print("\n🎉 Setup Complete!")
+ print("=" * 50)
+ print("\nNext steps:")
+ print("1. Source the environment file:")
+ print(" source .env")
+ print("\n2. Test Terraform configuration:")
+ print(" cd terraform && terraform init && terraform plan")
+ print("\n3. Deploy infrastructure:")
+ print(" terraform apply")
+ print("\n4. Start MCP servers:")
+ print(" Check MCP_GUIDE.md for server startup instructions")
+ print("\n📚 Documentation:")
+ print("- USAGE_GUIDE.md - Complete usage instructions")
+ print("- DEPLOYMENT_GUIDE.md - Deployment procedures")
+ print("- MCP_GUIDE.md - MCP server management")
+
+ # Security reminder
+ print("\n🔐 Security Reminder:")
+ print("- Never commit .env to version control")
+ print("- Use .gitignore to exclude .env files")
+ print("- Consider using environment-specific .env files (.env.production, etc.)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/setup_credentials.sh b/scripts/setup_credentials.sh
new file mode 100644
index 0000000..de2eed4
--- /dev/null
+++ b/scripts/setup_credentials.sh
@@ -0,0 +1,190 @@
+#!/bin/bash
+
+# Cloudflare Credential Setup Script
+# Interactive script to configure Cloudflare API credentials
+
+set -e
+
+echo "🚀 Cloudflare Credential Setup Wizard"
+echo "=================================================="
+echo
+
+echo "This script will help you configure your Cloudflare API credentials."
+echo "You'll need:"
+echo "1. Cloudflare API Token (with appropriate permissions)"
+echo "2. Cloudflare Account ID"
+echo "3. Optional: Zone ID for specific domain management"
+echo
+
+# Check if we're in the right directory
+if [[ ! "$PWD" =~ "cloudflare" ]]; then
+ echo "⚠️ Warning: This script should be run from the cloudflare directory"
+ echo " Current directory: $PWD"
+ read -p "Continue anyway? (y/n): " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Please navigate to the cloudflare directory and run this script again."
+ exit 1
+ fi
+fi
+
+# Function to validate API token format
+validate_api_token() {
+ local token="$1"
+ # Cloudflare API tokens are typically 40+ characters
+ [[ ${#token} -ge 40 ]]
+}
+
+# Function to validate Account ID format
+validate_account_id() {
+ local account_id="$1"
+ # Account IDs are 32-character hex strings
+ [[ "$account_id" =~ ^[a-f0-9]{32}$ ]]
+}
+
+# Function to validate Zone ID format
+validate_zone_id() {
+ local zone_id="$1"
+ # Zone IDs are 32-character hex strings
+ [[ "$zone_id" =~ ^[a-f0-9]{32}$ ]]
+}
+
+# Function to get validated input
+get_validated_input() {
+ local prompt="$1"
+ local validation_func="$2"
+ local secret="$3"
+
+ while true; do
+ if [[ "$secret" == "true" ]]; then
+ read -s -p "$prompt" value
+ echo
+ else
+ read -p "$prompt" value
+ fi
+
+ if [[ -n "$validation_func" ]]; then
+ if $validation_func "$value"; then
+ echo "$value"
+ return
+ else
+ echo "❌ Invalid format. Please try again."
+ fi
+ else
+ echo "$value"
+ return
+ fi
+ done
+}
+
+# Collect credentials
+echo "🔐 Cloudflare API Configuration"
+echo "------------------------------"
+echo
+
+# API Token
+echo "📋 Step 1: Cloudflare API Token"
+echo "Get your token from: https://dash.cloudflare.com/profile/api-tokens"
+echo "Required permissions: Zone:DNS:Edit, Zone:Page Rules:Edit, Account:Read"
+API_TOKEN=$(get_validated_input "API Token: " validate_api_token true)
+
+# Account ID
+echo
+echo "🏢 Step 2: Cloudflare Account ID"
+echo "Find your Account ID in the Cloudflare dashboard sidebar"
+echo "Format: 32-character hex string (e.g., 1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p)"
+ACCOUNT_ID=$(get_validated_input "Account ID: " validate_account_id false)
+
+# Zone ID (optional)
+echo
+echo "🌐 Step 3: Zone ID (Optional)"
+echo "If you want to manage a specific domain, provide its Zone ID"
+echo "Leave blank to skip"
+ZONE_ID=$(get_validated_input "Zone ID (optional): " "[[ -z \"\$1\" ]] || validate_zone_id \"\$1\"" false)
+
+# Create .env file
+echo
+echo "💾 Saving credentials..."
+
+# Read existing .env if it exists
+ENV_CONTENT=""
+if [[ -f ".env" ]]; then
+ # Preserve existing non-Cloudflare variables
+ while IFS= read -r line; do
+ if [[ ! "$line" =~ ^CLOUDFLARE_ ]] && [[ ! "$line" =~ ^#.*CLOUDFLARE ]]; then
+ ENV_CONTENT="$ENV_CONTENT$line\n"
+ fi
+ done < ".env"
+fi
+
+# Create new .env content
+cat > .env << EOF
+# OpenCode Environment Variables
+# Generated by setup_credentials.sh
+# IMPORTANT: Never commit this file to git
+
+# ============================================================================
+# CLOUDFLARE API CONFIGURATION
+# ============================================================================
+CLOUDFLARE_API_TOKEN="$API_TOKEN"
+CLOUDFLARE_ACCOUNT_ID="$ACCOUNT_ID"
+EOF
+
+# Add Zone ID if provided
+if [[ -n "$ZONE_ID" ]]; then
+ echo "CLOUDFLARE_ZONE_ID=\"$ZONE_ID\"" >> .env
+fi
+
+# Add preserved content
+if [[ -n "$ENV_CONTENT" ]]; then
+ echo >> .env
+ echo "$ENV_CONTENT" >> .env
+fi
+
+# Set secure permissions
+chmod 600 .env
+
+echo "✅ Credentials saved to: .env"
+echo "🔒 File permissions set to 600 (owner read/write only)"
+
+# Basic validation
+echo
+echo "🧪 Validating credentials..."
+if validate_api_token "$API_TOKEN" && validate_account_id "$ACCOUNT_ID"; then
+ echo "✅ Credential formats are valid"
+ echo "⚠️ Note: Full API connectivity test requires curl or python requests"
+else
+ echo "❌ Credential validation failed"
+ echo " Please check your inputs and try again"
+fi
+
+# Final instructions
+echo
+echo "🎉 Setup Complete!"
+echo "=================================================="
+echo
+echo "Next steps:"
+echo "1. Source the environment file:"
+echo " source .env"
+echo
+echo "2. Test Terraform configuration:"
+echo " cd terraform && terraform init && terraform plan"
+echo
+echo "3. Deploy infrastructure:"
+echo " terraform apply"
+echo
+echo "4. Start MCP servers:"
+echo " Check MCP_GUIDE.md for server startup instructions"
+echo
+echo "📚 Documentation:"
+echo "- USAGE_GUIDE.md - Complete usage instructions"
+echo "- DEPLOYMENT_GUIDE.md - Deployment procedures"
+echo "- MCP_GUIDE.md - MCP server management"
+echo
+echo "🔐 Security Reminder:"
+echo "- Never commit .env to version control"
+echo "- Use .gitignore to exclude .env files"
+echo "- Consider using environment-specific .env files (.env.production, etc.)"
+
+# Make script executable
+chmod +x "$0"
\ No newline at end of file
diff --git a/scripts/terraform_state_manager.py b/scripts/terraform_state_manager.py
new file mode 100644
index 0000000..bf3301d
--- /dev/null
+++ b/scripts/terraform_state_manager.py
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+"""
+Terraform State Backup and Recovery Manager
+Automated state management with versioning and rollback capabilities
+"""
+
+import os
+import json
+import shutil
+import hashlib
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Dict, List, Optional
+import argparse
+
+
+class TerraformStateManager:
+ """Manage Terraform state backups and recovery"""
+
+ def __init__(
+ self, terraform_dir: str = "terraform", backup_dir: str = "terraform_backups"
+ ):
+ self.terraform_dir = Path(terraform_dir)
+ self.backup_dir = Path(backup_dir)
+ self.state_file = self.terraform_dir / "terraform.tfstate"
+ self.backup_dir.mkdir(exist_ok=True)
+
+ def create_backup(self, description: str = "", auto_backup: bool = True) -> str:
+ """Create a backup of the current Terraform state"""
+ if not self.state_file.exists():
+ return "No state file found to backup"
+
+ # Generate backup filename with timestamp
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ backup_filename = f"state_backup_{timestamp}.tfstate"
+ backup_path = self.backup_dir / backup_filename
+
+ # Copy state file
+ shutil.copy2(self.state_file, backup_path)
+
+ # Create metadata file
+ metadata = {
+ "timestamp": timestamp,
+ "description": description,
+ "auto_backup": auto_backup,
+ "file_size": os.path.getsize(backup_path),
+ "file_hash": self._calculate_file_hash(backup_path),
+ }
+
+ metadata_path = backup_path.with_suffix(".json")
+ with open(metadata_path, "w") as f:
+ json.dump(metadata, f, indent=2)
+
+ return f"Backup created: {backup_filename}"
+
+ def list_backups(self) -> List[Dict]:
+ """List all available backups"""
+ backups = []
+
+ for file in self.backup_dir.glob("state_backup_*.tfstate"):
+ metadata_file = file.with_suffix(".json")
+
+ backup_info = {
+ "filename": file.name,
+ "path": str(file),
+ "size": file.stat().st_size,
+ "modified": datetime.fromtimestamp(file.stat().st_mtime),
+ }
+
+ if metadata_file.exists():
+ with open(metadata_file, "r") as f:
+ backup_info.update(json.load(f))
+
+ backups.append(backup_info)
+
+ # Sort by modification time (newest first)
+ backups.sort(key=lambda x: x["modified"], reverse=True)
+ return backups
+
+ def restore_backup(self, backup_filename: str, dry_run: bool = False) -> str:
+ """Restore a specific backup"""
+ backup_path = self.backup_dir / backup_filename
+
+ if not backup_path.exists():
+ return f"Backup file not found: {backup_filename}"
+
+ # Create backup of current state before restore
+ if self.state_file.exists() and not dry_run:
+ self.create_backup("Pre-restore backup", auto_backup=True)
+
+ if dry_run:
+ return f"Dry run: Would restore {backup_filename}"
+
+ # Perform restore
+ shutil.copy2(backup_path, self.state_file)
+
+ return f"State restored from: {backup_filename}"
+
+ def cleanup_old_backups(
+ self, keep_days: int = 30, keep_count: int = 10
+ ) -> List[str]:
+ """Clean up old backups based on age and count"""
+ backups = self.list_backups()
+
+ if not backups:
+ return ["No backups found to clean up"]
+
+ cutoff_date = datetime.now() - timedelta(days=keep_days)
+ backups_to_delete = []
+
+ # Delete backups older than keep_days
+ for backup in backups:
+ if backup["modified"] < cutoff_date:
+ backups_to_delete.append(backup)
+
+ # If we have more than keep_count backups, delete the oldest ones
+ if len(backups) > keep_count:
+ # Keep the newest keep_count backups
+ backups_to_keep = backups[:keep_count]
+ backups_to_delete.extend([b for b in backups if b not in backups_to_keep])
+
+ # Remove duplicates
+ backups_to_delete = list({b["filename"]: b for b in backups_to_delete}.values())
+
+ deleted_files = []
+ for backup in backups_to_delete:
+ try:
+ # Delete state file
+ state_file = Path(backup["path"])
+ if state_file.exists():
+ state_file.unlink()
+ deleted_files.append(state_file.name)
+
+ # Delete metadata file
+ metadata_file = state_file.with_suffix(".json")
+ if metadata_file.exists():
+ metadata_file.unlink()
+ deleted_files.append(metadata_file.name)
+
+ except Exception as e:
+ print(f"Error deleting {backup['filename']}: {e}")
+
+ return deleted_files
+
+ def verify_backup_integrity(self, backup_filename: str) -> Dict[str, bool]:
+ """Verify the integrity of a backup"""
+ backup_path = self.backup_dir / backup_filename
+ metadata_path = backup_path.with_suffix(".json")
+
+ if not backup_path.exists():
+ return {"exists": False, "metadata_exists": False, "integrity": False}
+
+ if not metadata_path.exists():
+ return {"exists": True, "metadata_exists": False, "integrity": False}
+
+ # Check file size and hash
+ with open(metadata_path, "r") as f:
+ metadata = json.load(f)
+
+ current_size = backup_path.stat().st_size
+ current_hash = self._calculate_file_hash(backup_path)
+
+ size_matches = current_size == metadata.get("file_size", 0)
+ hash_matches = current_hash == metadata.get("file_hash", "")
+
+ return {
+ "exists": True,
+ "metadata_exists": True,
+ "size_matches": size_matches,
+ "hash_matches": hash_matches,
+ "integrity": size_matches and hash_matches,
+ }
+
+ def get_state_statistics(self) -> Dict:
+ """Get statistics about current state and backups"""
+ backups = self.list_backups()
+
+ stats = {
+ "current_state_exists": self.state_file.exists(),
+ "current_state_size": self.state_file.stat().st_size
+ if self.state_file.exists()
+ else 0,
+ "backup_count": len(backups),
+ "oldest_backup": min([b["modified"] for b in backups]) if backups else None,
+ "newest_backup": max([b["modified"] for b in backups]) if backups else None,
+ "total_backup_size": sum(b["size"] for b in backups),
+ "backups_with_issues": [],
+ }
+
+ # Check backup integrity
+ for backup in backups:
+ integrity = self.verify_backup_integrity(backup["filename"])
+ if not integrity["integrity"]:
+ stats["backups_with_issues"].append(
+ {"filename": backup["filename"], "integrity": integrity}
+ )
+
+ return stats
+
+ def _calculate_file_hash(self, file_path: Path) -> str:
+ """Calculate SHA256 hash of a file"""
+ hasher = hashlib.sha256()
+ with open(file_path, "rb") as f:
+ for chunk in iter(lambda: f.read(4096), b""):
+ hasher.update(chunk)
+ return hasher.hexdigest()
+
+
+def main():
+ """Command-line interface for Terraform state management"""
+ parser = argparse.ArgumentParser(
+ description="Terraform State Backup and Recovery Manager"
+ )
+ parser.add_argument(
+ "action",
+ choices=["backup", "list", "restore", "cleanup", "stats", "verify"],
+ help="Action to perform",
+ )
+ parser.add_argument("--filename", help="Backup filename for restore/verify")
+ parser.add_argument("--description", help="Description for backup")
+ parser.add_argument("--dry-run", action="store_true", help="Dry run mode")
+ parser.add_argument(
+ "--keep-days", type=int, default=30, help="Days to keep backups"
+ )
+ parser.add_argument(
+ "--keep-count", type=int, default=10, help="Number of backups to keep"
+ )
+ parser.add_argument(
+ "--terraform-dir", default="terraform", help="Terraform directory"
+ )
+ parser.add_argument(
+ "--backup-dir", default="terraform_backups", help="Backup directory"
+ )
+
+ args = parser.parse_args()
+
+ manager = TerraformStateManager(args.terraform_dir, args.backup_dir)
+
+ if args.action == "backup":
+ result = manager.create_backup(
+ args.description or "Manual backup", auto_backup=False
+ )
+ print(f"✅ {result}")
+
+ elif args.action == "list":
+ backups = manager.list_backups()
+ print("📋 Available Backups:")
+ print("-" * 80)
+ for backup in backups:
+ print(f"📁 {backup['filename']}")
+ print(f" Size: {backup['size']:,} bytes")
+ print(f" Modified: {backup['modified'].strftime('%Y-%m-%d %H:%M:%S')}")
+ if "description" in backup:
+ print(f" Description: {backup['description']}")
+ print()
+
+ elif args.action == "restore":
+ if not args.filename:
+ print("❌ Error: --filename argument required for restore")
+ return
+
+ result = manager.restore_backup(args.filename, args.dry_run)
+ print(f"🔁 {result}")
+
+ elif args.action == "cleanup":
+ deleted = manager.cleanup_old_backups(args.keep_days, args.keep_count)
+ if deleted:
+ print("🗑️ Cleaned up backups:")
+ for filename in deleted:
+ print(f" - {filename}")
+ else:
+ print("✅ No backups needed cleanup")
+
+ elif args.action == "stats":
+ stats = manager.get_state_statistics()
+ print("📊 Terraform State Statistics")
+ print("-" * 40)
+ print(
+ f"Current state exists: {'✅' if stats['current_state_exists'] else '❌'}"
+ )
+ print(f"Current state size: {stats['current_state_size']:,} bytes")
+ print(f"Backup count: {stats['backup_count']}")
+ if stats["oldest_backup"]:
+ print(f"Oldest backup: {stats['oldest_backup'].strftime('%Y-%m-%d')}")
+ print(f"Newest backup: {stats['newest_backup'].strftime('%Y-%m-%d')}")
+ print(f"Total backup size: {stats['total_backup_size']:,} bytes")
+
+ if stats["backups_with_issues"]:
+ print(f"\n⚠️ Backups with issues: {len(stats['backups_with_issues'])}")
+ for issue in stats["backups_with_issues"]:
+ print(f" - {issue['filename']}")
+
+ elif args.action == "verify":
+ if not args.filename:
+ print("❌ Error: --filename argument required for verify")
+ return
+
+ integrity = manager.verify_backup_integrity(args.filename)
+ print(f"🔍 Integrity check for {args.filename}")
+ print(f" File exists: {'✅' if integrity['exists'] else '❌'}")
+ print(f" Metadata exists: {'✅' if integrity['metadata_exists'] else '❌'}")
+ if integrity["metadata_exists"]:
+ print(f" Size matches: {'✅' if integrity['size_matches'] else '❌'}")
+ print(f" Hash matches: {'✅' if integrity['hash_matches'] else '❌'}")
+ print(f" Overall integrity: {'✅' if integrity['integrity'] else '❌'}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/waf-and-plan-invariants.sh b/scripts/waf-and-plan-invariants.sh
new file mode 100644
index 0000000..d78d173
--- /dev/null
+++ b/scripts/waf-and-plan-invariants.sh
@@ -0,0 +1,393 @@
+#!/usr/bin/env bash
+# ============================================================================
+# WAF + PLAN INVARIANTS CHECKER
+# ============================================================================
+# Enforces security+plan gating invariants for VaultMesh Cloudflare IaC.
+# Run from repo root: bash scripts/waf-and-plan-invariants.sh
+#
+# Exit codes:
+# 0 = All invariants pass
+# 1 = One or more invariants violated
+#
+# Governed by: RED-BOOK.md
+# ============================================================================
+
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+NC='\033[0m'
+
+echo "============================================"
+echo " VaultMesh WAF + Plan Invariants Check"
+echo "============================================"
+echo ""
+
+FAILED=0
+
+echo "── 0. Toolchain Versions ──"
+terraform version || true
+python3 --version || true
+python3 -m pip --version || true
+python3 -m pytest --version || true
+python3 -m mcp.waf_intelligence --version || true
+
+echo ""
+
+echo "── 1. WAF Intel Analyzer Regression ──"
+if python3 -m pytest -q tests/test_waf_intelligence_analyzer.py; then
+ echo -e "${GREEN}✓${NC} 1.1 Analyzer regression test passed"
+else
+ echo -e "${RED}✗${NC} 1.1 Analyzer regression test failed"
+ FAILED=1
+fi
+
+echo ""
+echo "── 2. WAF Intel CLI Contract ──"
+
+TMP_DIR="${TMPDIR:-/tmp}"
+WAF_JSON_FILE="$(mktemp -p "$TMP_DIR" waf-intel.XXXXXX.json)"
+if python3 -m mcp.waf_intelligence --file terraform/waf.tf --format json --limit 5 >"$WAF_JSON_FILE"; then
+ if python3 - "$WAF_JSON_FILE" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+
+insights = payload.get("insights")
+if not isinstance(insights, list):
+ raise SystemExit("waf_intel: insights is not a list")
+
+if insights:
+ raise SystemExit(f"waf_intel: expected 0 insights, got {len(insights)}")
+
+print("ok")
+PY
+ then
+ echo -e "${GREEN}✓${NC} 2.1 WAF Intel JSON parses and insights are empty"
+ else
+ echo -e "${RED}✗${NC} 2.1 WAF Intel JSON contract violated"
+ cat "$WAF_JSON_FILE"
+ FAILED=1
+ fi
+else
+ echo -e "${RED}✗${NC} 2.1 WAF Intel CLI failed"
+ FAILED=1
+fi
+rm -f "$WAF_JSON_FILE"
+
+echo ""
+echo "── 3. Terraform Format + Validate + Plan Gates ──"
+
+cd terraform
+
+if terraform fmt -check -recursive >/dev/null 2>&1; then
+ echo -e "${GREEN}✓${NC} 3.1 Terraform formatting OK"
+else
+ echo -e "${RED}✗${NC} 3.1 Terraform formatting required"
+ echo " Run: cd terraform && terraform fmt -recursive"
+ FAILED=1
+fi
+
+terraform init -backend=false -input=false >/dev/null 2>&1
+if terraform validate -no-color >/dev/null 2>&1; then
+ echo -e "${GREEN}✓${NC} 3.2 Terraform validate OK"
+else
+ echo -e "${RED}✗${NC} 3.2 Terraform validate failed"
+ terraform validate -no-color
+ FAILED=1
+fi
+
+PLAN_FREE_OUT="$(mktemp -p "$TMP_DIR" tf-plan-free.XXXXXX.out)"
+PLAN_PRO_OUT="$(mktemp -p "$TMP_DIR" tf-plan-pro.XXXXXX.out)"
+PLAN_FREE_JSON="$(mktemp -p "$TMP_DIR" tf-plan-free.XXXXXX.json)"
+PLAN_PRO_JSON="$(mktemp -p "$TMP_DIR" tf-plan-pro.XXXXXX.json)"
+rm -f "$PLAN_FREE_OUT" "$PLAN_PRO_OUT"
+
+if terraform plan -no-color -input=false -lock=false -refresh=false -out="$PLAN_FREE_OUT" -var-file=assurance_free.tfvars >/dev/null; then
+ if terraform show -json "$PLAN_FREE_OUT" >"$PLAN_FREE_JSON"; then
+ if output="$(
+ python3 - "$PLAN_FREE_JSON" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+try:
+ with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+except json.JSONDecodeError as e:
+ print(f"json parse error: {e}")
+ raise SystemExit(2)
+
+resource_changes = payload.get("resource_changes")
+planned_values = payload.get("planned_values")
+
+if not isinstance(resource_changes, list) or not isinstance(planned_values, dict):
+ print("invalid plan json: missing resource_changes[] and/or planned_values{}")
+ raise SystemExit(2)
+
+addresses = [
+ rc.get("address", "")
+ for rc in resource_changes
+ if isinstance(rc, dict) and isinstance(rc.get("address"), str)
+]
+
+managed_waf = sum(1 for a in addresses if a.startswith("cloudflare_ruleset.managed_waf["))
+bot_mgmt = sum(1 for a in addresses if a.startswith("cloudflare_bot_management.domains["))
+
+if managed_waf != 0 or bot_mgmt != 0:
+ print(f"expected managed_waf=0 bot_management=0, got managed_waf={managed_waf} bot_management={bot_mgmt}")
+ for addr in sorted(
+ a
+ for a in addresses
+ if a.startswith("cloudflare_ruleset.managed_waf[") or a.startswith("cloudflare_bot_management.domains[")
+ ):
+ print(f"- {addr}")
+ raise SystemExit(2)
+PY
+ )"; then
+ echo -e "${GREEN}✓${NC} 3.3 Free-plan gate OK (managed_waf=0 bot_management=0)"
+ else
+ echo -e "${RED}✗${NC} 3.3 Free-plan gate violated"
+ if [[ -n "${output:-}" ]]; then
+ echo "$output" | sed 's/^/ /'
+ fi
+ FAILED=1
+ fi
+ else
+ echo -e "${RED}✗${NC} 3.3 terraform show -json failed (free)"
+ FAILED=1
+ fi
+else
+ echo -e "${RED}✗${NC} 3.3 Terraform plan failed (free)"
+ terraform show -no-color "$PLAN_FREE_OUT" 2>/dev/null || true
+ FAILED=1
+fi
+
+if terraform plan -no-color -input=false -lock=false -refresh=false -out="$PLAN_PRO_OUT" -var-file=assurance_pro.tfvars >/dev/null; then
+ if terraform show -json "$PLAN_PRO_OUT" >"$PLAN_PRO_JSON"; then
+ if output="$(
+ python3 - "$PLAN_PRO_JSON" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+try:
+ with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+except json.JSONDecodeError as e:
+ print(f"json parse error: {e}")
+ raise SystemExit(2)
+
+resource_changes = payload.get("resource_changes")
+planned_values = payload.get("planned_values")
+
+if not isinstance(resource_changes, list) or not isinstance(planned_values, dict):
+ print("invalid plan json: missing resource_changes[] and/or planned_values{}")
+ raise SystemExit(2)
+
+addresses = [
+ rc.get("address", "")
+ for rc in resource_changes
+ if isinstance(rc, dict) and isinstance(rc.get("address"), str)
+]
+
+managed_waf = sum(1 for a in addresses if a.startswith("cloudflare_ruleset.managed_waf["))
+bot_mgmt = sum(1 for a in addresses if a.startswith("cloudflare_bot_management.domains["))
+
+if managed_waf != 1 or bot_mgmt != 1:
+ print("expected managed_waf=1 bot_management=1")
+ print(f"got managed_waf={managed_waf} bot_management={bot_mgmt}")
+ print("observed:")
+ for addr in sorted(
+ a
+ for a in addresses
+ if a.startswith("cloudflare_ruleset.managed_waf[") or a.startswith("cloudflare_bot_management.domains[")
+ ):
+ print(f"- {addr}")
+ raise SystemExit(2)
+PY
+ )"; then
+ echo -e "${GREEN}✓${NC} 3.4 Paid-plan gate OK (managed_waf=1 bot_management=1)"
+ else
+ echo -e "${RED}✗${NC} 3.4 Paid-plan gate violated"
+ if [[ -n "${output:-}" ]]; then
+ echo "$output" | sed 's/^/ /'
+ fi
+ FAILED=1
+ fi
+ else
+ echo -e "${RED}✗${NC} 3.4 terraform show -json failed (pro)"
+ FAILED=1
+ fi
+else
+ echo -e "${RED}✗${NC} 3.4 Terraform plan failed (pro)"
+ terraform show -no-color "$PLAN_PRO_OUT" 2>/dev/null || true
+ FAILED=1
+fi
+
+PLAN_NEG_FREE_OUT="$(mktemp -p "$TMP_DIR" tf-plan-neg-free.XXXXXX.out)"
+PLAN_NEG_PRO_OUT="$(mktemp -p "$TMP_DIR" tf-plan-neg-pro.XXXXXX.out)"
+PLAN_NEG_FREE_JSON="$(mktemp -p "$TMP_DIR" tf-plan-neg-free.XXXXXX.json)"
+PLAN_NEG_PRO_JSON="$(mktemp -p "$TMP_DIR" tf-plan-neg-pro.XXXXXX.json)"
+rm -f "$PLAN_NEG_FREE_OUT" "$PLAN_NEG_PRO_OUT"
+
+echo ""
+echo "── 4. Negative Controls (Prove the gate bites) ──"
+
+if terraform plan -no-color -input=false -lock=false -refresh=false -out="$PLAN_NEG_FREE_OUT" -var-file=assurance_negative_free_should_fail.tfvars >/dev/null; then
+ if terraform show -json "$PLAN_NEG_FREE_OUT" >"$PLAN_NEG_FREE_JSON"; then
+ if output="$(
+ python3 - "$PLAN_NEG_FREE_JSON" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+try:
+ with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+except json.JSONDecodeError as e:
+ print(f"json parse error: {e}")
+ raise SystemExit(2)
+
+resource_changes = payload.get("resource_changes")
+planned_values = payload.get("planned_values")
+
+if not isinstance(resource_changes, list) or not isinstance(planned_values, dict):
+ print("invalid plan json: missing resource_changes[] and/or planned_values{}")
+ raise SystemExit(2)
+
+addresses = [
+ rc.get("address", "")
+ for rc in resource_changes
+ if isinstance(rc, dict) and isinstance(rc.get("address"), str)
+]
+
+managed_waf = sum(1 for a in addresses if a.startswith("cloudflare_ruleset.managed_waf["))
+bot_mgmt = sum(1 for a in addresses if a.startswith("cloudflare_bot_management.domains["))
+
+if managed_waf != 0 or bot_mgmt != 0:
+ print(f"expected managed_waf=0 bot_management=0, got managed_waf={managed_waf} bot_management={bot_mgmt}")
+ for addr in sorted(
+ a
+ for a in addresses
+ if a.startswith("cloudflare_ruleset.managed_waf[") or a.startswith("cloudflare_bot_management.domains[")
+ ):
+ print(f"- {addr}")
+ raise SystemExit(2)
+
+print("ok")
+PY
+ )"; then
+ echo -e "${RED}✗${NC} 4.1 Negative free-plan control unexpectedly passed"
+ FAILED=1
+ else
+ if [[ "${output:-}" == *"expected managed_waf=0 bot_management=0"* ]]; then
+ echo -e "${GREEN}✓${NC} 4.1 Negative free-plan control failed as expected"
+ else
+ echo -e "${RED}✗${NC} 4.1 Negative free-plan control failed (unexpected error)"
+ if [[ -n "${output:-}" ]]; then
+ echo "$output" | sed 's/^/ /'
+ fi
+ FAILED=1
+ fi
+ fi
+ else
+ echo -e "${RED}✗${NC} 4.1 terraform show -json failed (negative free)"
+ FAILED=1
+ fi
+else
+ echo -e "${RED}✗${NC} 4.1 Terraform plan failed (negative free)"
+ FAILED=1
+fi
+
+if terraform plan -no-color -input=false -lock=false -refresh=false -out="$PLAN_NEG_PRO_OUT" -var-file=assurance_negative_pro_should_fail.tfvars >/dev/null; then
+ if terraform show -json "$PLAN_NEG_PRO_OUT" >"$PLAN_NEG_PRO_JSON"; then
+ if output="$(
+ python3 - "$PLAN_NEG_PRO_JSON" <<'PY'
+import json
+import sys
+
+path = sys.argv[1]
+try:
+ with open(path, "r", encoding="utf-8") as f:
+ payload = json.load(f)
+except json.JSONDecodeError as e:
+ print(f"json parse error: {e}")
+ raise SystemExit(2)
+
+resource_changes = payload.get("resource_changes")
+planned_values = payload.get("planned_values")
+
+if not isinstance(resource_changes, list) or not isinstance(planned_values, dict):
+ print("invalid plan json: missing resource_changes[] and/or planned_values{}")
+ raise SystemExit(2)
+
+addresses = [
+ rc.get("address", "")
+ for rc in resource_changes
+ if isinstance(rc, dict) and isinstance(rc.get("address"), str)
+]
+
+managed_waf = sum(1 for a in addresses if a.startswith("cloudflare_ruleset.managed_waf["))
+bot_mgmt = sum(1 for a in addresses if a.startswith("cloudflare_bot_management.domains["))
+
+if managed_waf != 1 or bot_mgmt != 1:
+ print("expected managed_waf=1 bot_management=1")
+ print(f"got managed_waf={managed_waf} bot_management={bot_mgmt}")
+ print("observed:")
+ for addr in sorted(
+ a
+ for a in addresses
+ if a.startswith("cloudflare_ruleset.managed_waf[") or a.startswith("cloudflare_bot_management.domains[")
+ ):
+ print(f"- {addr}")
+ raise SystemExit(2)
+
+print("ok")
+PY
+ )"; then
+ echo -e "${RED}✗${NC} 4.2 Negative paid-plan control unexpectedly passed"
+ FAILED=1
+ else
+ if [[ "${output:-}" == *"expected managed_waf=1 bot_management=1"* ]]; then
+ echo -e "${GREEN}✓${NC} 4.2 Negative paid-plan control failed as expected"
+ else
+ echo -e "${RED}✗${NC} 4.2 Negative paid-plan control failed (unexpected error)"
+ if [[ -n "${output:-}" ]]; then
+ echo "$output" | sed 's/^/ /'
+ fi
+ FAILED=1
+ fi
+ fi
+ else
+ echo -e "${RED}✗${NC} 4.2 terraform show -json failed (negative pro)"
+ FAILED=1
+ fi
+else
+ echo -e "${RED}✗${NC} 4.2 Terraform plan failed (negative pro)"
+ FAILED=1
+fi
+
+rm -f "$PLAN_FREE_OUT" "$PLAN_PRO_OUT" "$PLAN_FREE_JSON" "$PLAN_PRO_JSON" "$PLAN_NEG_FREE_OUT" "$PLAN_NEG_PRO_OUT" "$PLAN_NEG_FREE_JSON" "$PLAN_NEG_PRO_JSON"
+
+cd "$REPO_ROOT"
+
+echo ""
+echo "============================================"
+echo " Summary"
+echo "============================================"
+
+if [[ $FAILED -gt 0 ]]; then
+ echo -e "${RED}WAF + plan invariants violated. Fix before merging.${NC}"
+ exit 1
+fi
+
+echo -e "${GREEN}All WAF + plan invariants pass. ✓${NC}"
+exit 0
diff --git a/terraform/README.md b/terraform/README.md
index 373f15b..6274afb 100644
--- a/terraform/README.md
+++ b/terraform/README.md
@@ -38,6 +38,8 @@ cloudflare_account_name = "your-account-name"
tunnel_secret_vaultmesh = "base64-encoded-secret"
tunnel_secret_offsec = "base64-encoded-secret"
admin_emails = ["admin@vaultmesh.org"]
+enable_managed_waf = true
+enable_bot_management = false
EOF
# Plan
@@ -47,6 +49,31 @@ terraform plan
terraform apply
```
+## Plan-Aware Security Features
+
+- `enable_managed_waf` applies the managed WAF ruleset only when the zone `plan` is not `"free"`.
+- `enable_bot_management` applies bot management settings only when the zone `plan` is not `"free"`.
+
+This lets `terraform apply` succeed on Free-plan zones (DNS, tunnels, Access, settings) while keeping the security posture ready for plan upgrades.
+
+### WAF Truth Table
+
+| Zone plan (`var.domains[*].plan`) | `enable_managed_waf` | `enable_bot_management` | Expected resources |
+| --- | --- | --- | --- |
+| `free` | any | any | `cloudflare_ruleset.security_rules` only |
+| not `free` | `false` | any | `cloudflare_ruleset.security_rules` only |
+| not `free` | `true` | `false` | `cloudflare_ruleset.security_rules`, `cloudflare_ruleset.managed_waf` |
+| not `free` | `true` | `true` | `cloudflare_ruleset.security_rules`, `cloudflare_ruleset.managed_waf`, `cloudflare_bot_management.domains` |
+
+### Assurance Varfiles
+
+For deterministic, token-format-safe gating checks (no apply), use:
+
+```bash
+terraform plan -refresh=false -var-file=assurance_free.tfvars
+terraform plan -refresh=false -var-file=assurance_pro.tfvars
+```
+
## Generate Tunnel Secrets
```bash
diff --git a/terraform/assurance_free.tfvars b/terraform/assurance_free.tfvars
new file mode 100644
index 0000000..ebc80f5
--- /dev/null
+++ b/terraform/assurance_free.tfvars
@@ -0,0 +1,35 @@
+cloudflare_api_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Placeholder (format-valid)
+cloudflare_account_id = "00000000000000000000000000000000" # Placeholder (format-valid)
+cloudflare_account_name = ""
+
+# Exercise empty-list safety
+trusted_admin_ips = []
+blocked_countries = []
+
+# Even when flags are true, free-plan zones must gate these resources off
+enable_managed_waf = true
+enable_bot_management = true
+
+# Keep the full set of expected zones so hard-coded references stay valid
+domains = {
+ "offsec.global" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecglobal.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecagent.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecshield.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "vaultmesh.org" = {
+ plan = "free"
+ jump_start = false
+ }
+}
diff --git a/terraform/assurance_negative_free_should_fail.tfvars b/terraform/assurance_negative_free_should_fail.tfvars
new file mode 100644
index 0000000..c8fd719
--- /dev/null
+++ b/terraform/assurance_negative_free_should_fail.tfvars
@@ -0,0 +1,34 @@
+cloudflare_api_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Placeholder (format-valid)
+cloudflare_account_id = "00000000000000000000000000000000" # Placeholder (format-valid)
+cloudflare_account_name = ""
+
+trusted_admin_ips = []
+blocked_countries = []
+
+enable_managed_waf = true
+enable_bot_management = true
+
+# Intentionally violates the "free plan must gate managed WAF + bot mgmt off".
+# Used by scripts/waf-and-plan-invariants.sh negative-control check.
+domains = {
+ "offsec.global" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecglobal.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecagent.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecshield.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "vaultmesh.org" = {
+ plan = "pro"
+ jump_start = false
+ }
+}
diff --git a/terraform/assurance_negative_pro_should_fail.tfvars b/terraform/assurance_negative_pro_should_fail.tfvars
new file mode 100644
index 0000000..f92d0f3
--- /dev/null
+++ b/terraform/assurance_negative_pro_should_fail.tfvars
@@ -0,0 +1,34 @@
+cloudflare_api_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Placeholder (format-valid)
+cloudflare_account_id = "00000000000000000000000000000000" # Placeholder (format-valid)
+cloudflare_account_name = ""
+
+trusted_admin_ips = []
+blocked_countries = []
+
+enable_managed_waf = true
+enable_bot_management = false
+
+# Intentionally violates the "pro plan must create exactly 1 managed_waf + 1 bot_management" invariant.
+# Used by scripts/waf-and-plan-invariants.sh negative-control check.
+domains = {
+ "offsec.global" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecglobal.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecagent.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecshield.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "vaultmesh.org" = {
+ plan = "pro"
+ jump_start = false
+ }
+}
diff --git a/terraform/assurance_pro.tfvars b/terraform/assurance_pro.tfvars
new file mode 100644
index 0000000..3e78858
--- /dev/null
+++ b/terraform/assurance_pro.tfvars
@@ -0,0 +1,34 @@
+cloudflare_api_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Placeholder (format-valid)
+cloudflare_account_id = "00000000000000000000000000000000" # Placeholder (format-valid)
+cloudflare_account_name = ""
+
+# Exercise empty-list safety
+trusted_admin_ips = []
+blocked_countries = []
+
+enable_managed_waf = true
+enable_bot_management = true
+
+# Mark at least one zone as non-free so plan includes managed WAF + bot mgmt resources.
+domains = {
+ "offsec.global" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecglobal.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecagent.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "offsecshield.com" = {
+ plan = "free"
+ jump_start = false
+ }
+ "vaultmesh.org" = {
+ plan = "pro"
+ jump_start = false
+ }
+}
diff --git a/terraform/main.tf b/terraform/main.tf
index 84d5476..b5d2bcf 100644
--- a/terraform/main.tf
+++ b/terraform/main.tf
@@ -20,10 +20,7 @@ data "cloudflare_accounts" "main" {
}
locals {
- # Use account ID from data source if available, otherwise use variable
- account_id = (
- var.cloudflare_account_name != "" && length(data.cloudflare_accounts.main) > 0 && length(data.cloudflare_accounts.main[0].accounts) > 0
- ? data.cloudflare_accounts.main[0].accounts[0].id
- : var.cloudflare_account_id
- )
+ # Use account ID from data source if available, otherwise fall back to variable.
+ # `try()` avoids invalid index errors when the data source count is 0 or no accounts match.
+ account_id = try(data.cloudflare_accounts.main[0].accounts[0].id, var.cloudflare_account_id)
}
diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars
index e5cd35e..7289e1b 100644
--- a/terraform/terraform.tfvars
+++ b/terraform/terraform.tfvars
@@ -1,3 +1,3 @@
-cloudflare_api_token = "placeholder-token"
-cloudflare_account_id = "placeholder-account-id"
-cloudflare_account_name = "" # Leave empty to use hardcoded account_id
+cloudflare_api_token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Placeholder (format-valid, not a real token)
+cloudflare_account_id = "00000000000000000000000000000000" # Placeholder (format-valid, not a real account ID)
+cloudflare_account_name = "" # Leave empty to use cloudflare_account_id
diff --git a/terraform/variables.tf b/terraform/variables.tf
index 5fc41ff..2e20134 100644
--- a/terraform/variables.tf
+++ b/terraform/variables.tf
@@ -64,3 +64,15 @@ variable "blocked_countries" {
type = list(string)
default = ["CN", "RU", "KP", "IR"]
}
+
+variable "enable_managed_waf" {
+ description = "Enable Cloudflare managed WAF rulesets (requires WAF entitlement; typically not available on Free plan)."
+ type = bool
+ default = true
+}
+
+variable "enable_bot_management" {
+ description = "Enable Cloudflare Bot Management settings (requires Bot Management entitlement)."
+ type = bool
+ default = false
+}
diff --git a/terraform/waf.tf b/terraform/waf.tf
index 728a52a..93da5f4 100644
--- a/terraform/waf.tf
+++ b/terraform/waf.tf
@@ -11,7 +11,7 @@ resource "cloudflare_ruleset" "security_rules" {
# Rule 1: Block requests to /admin from non-trusted IPs
rules {
action = "block"
- expression = "(http.request.uri.path contains \"/admin\") and not (ip.src in {${join(" ", var.trusted_admin_ips)}})"
+ expression = length(var.trusted_admin_ips) > 0 ? "(http.request.uri.path contains \"/admin\") and not (ip.src in {${join(" ", var.trusted_admin_ips)}})" : "false"
description = "Block admin access from untrusted IPs"
enabled = length(var.trusted_admin_ips) > 0
}
@@ -19,9 +19,9 @@ resource "cloudflare_ruleset" "security_rules" {
# Rule 2: Challenge suspicious countries
rules {
action = "managed_challenge"
- expression = "(ip.src.country in {\"${join("\" \"", var.blocked_countries)}\"})"
+ expression = length(var.blocked_countries) > 0 ? format("(ip.src.country in {%s})", join(" ", [for c in var.blocked_countries : format("\"%s\"", c)])) : "false"
description = "Challenge traffic from high-risk countries"
- enabled = true
+ enabled = length(var.blocked_countries) > 0
}
# Rule 3: Block known bad user agents
@@ -49,11 +49,14 @@ resource "cloudflare_ruleset" "security_rules" {
# Enable Cloudflare Managed WAF Ruleset
resource "cloudflare_ruleset" "managed_waf" {
- for_each = cloudflare_zone.domains
- zone_id = each.value.id
- name = "Managed WAF"
- kind = "zone"
- phase = "http_request_firewall_managed"
+ for_each = {
+ for domain, zone in cloudflare_zone.domains : domain => zone
+ if var.enable_managed_waf && var.domains[domain].plan != "free"
+ }
+ zone_id = each.value.id
+ name = "Managed WAF"
+ kind = "zone"
+ phase = "http_request_firewall_managed"
# Cloudflare Managed Ruleset
rules {
@@ -80,7 +83,10 @@ resource "cloudflare_ruleset" "managed_waf" {
# Bot Management (if available on plan)
resource "cloudflare_bot_management" "domains" {
- for_each = cloudflare_zone.domains
+ for_each = {
+ for domain, zone in cloudflare_zone.domains : domain => zone
+ if var.enable_bot_management && var.domains[domain].plan != "free"
+ }
zone_id = each.value.id
enable_js = true
fight_mode = true
diff --git a/tests/test_mcp_cloudflare_safe_ingress.py b/tests/test_mcp_cloudflare_safe_ingress.py
new file mode 100644
index 0000000..c30b3af
--- /dev/null
+++ b/tests/test_mcp_cloudflare_safe_ingress.py
@@ -0,0 +1,22 @@
+from mcp.cloudflare_safe.cloudflare_api import parse_cloudflared_config_ingress
+
+
+def test_parse_cloudflared_config_ingress_extracts_hostnames_and_services():
+ sample = """\
+tunnel: 00000000-0000-0000-0000-000000000000
+credentials-file: /etc/cloudflared/0000.json
+
+ingress:
+ - hostname: "api.example.com"
+ service: http://127.0.0.1:8080
+ - hostname: app.example.com
+ service: "http://127.0.0.1:3000"
+ - service: http_status:404
+"""
+
+ rules = parse_cloudflared_config_ingress(sample)
+
+ assert rules == [
+ {"hostname": "api.example.com", "service": "http://127.0.0.1:8080"},
+ {"hostname": "app.example.com", "service": "http://127.0.0.1:3000"},
+ ]
diff --git a/tests/test_waf_intelligence_analyzer.py b/tests/test_waf_intelligence_analyzer.py
new file mode 100644
index 0000000..f2fb059
--- /dev/null
+++ b/tests/test_waf_intelligence_analyzer.py
@@ -0,0 +1,43 @@
+from mcp.waf_intelligence.analyzer import WAFRuleAnalyzer
+
+
+def test_analyzer_detects_managed_waf_ruleset():
+ analyzer = WAFRuleAnalyzer()
+
+ tf = """
+resource "cloudflare_ruleset" "managed_waf" {
+ name = "Managed WAF"
+ kind = "zone"
+ phase = "http_request_firewall_managed"
+
+ rules {
+ action = "execute"
+ action_parameters {
+ id = "efb7b8c949ac4650a09736fc376e9aee"
+ }
+ expression = "true"
+ description = "Execute Cloudflare Managed Ruleset"
+ enabled = true
+ }
+}
+"""
+
+ result = analyzer.analyze_terraform_text("snippet.tf", tf, min_severity="warning")
+ assert result.violations == []
+
+
+def test_analyzer_warns_when_managed_waf_missing():
+ analyzer = WAFRuleAnalyzer()
+
+ tf = """
+resource "cloudflare_ruleset" "security_rules" {
+ name = "Security Rules"
+ kind = "zone"
+ phase = "http_request_firewall_custom"
+}
+"""
+
+ result = analyzer.analyze_terraform_text("snippet.tf", tf, min_severity="warning")
+ assert [v.message for v in result.violations] == [
+ "No managed WAF rules detected in this snippet."
+ ]
diff --git a/validate_registry.sh b/validate_registry.sh
new file mode 100755
index 0000000..2090ba7
--- /dev/null
+++ b/validate_registry.sh
@@ -0,0 +1,60 @@
+#!/bin/bash
+# Local Registry Validation Script
+# Run this before commits to ensure registry integrity
+
+echo "🔍 Local Registry Validation"
+echo "============================"
+
+# Set Python path for MCP servers
+export PYTHONPATH="/Users/sovereign/work-core"
+
+cd /Users/sovereign/work-core/cloudflare
+
+# Generate fresh registry
+echo "📝 Generating fresh capability registry..."
+python3 generate_capability_registry_v2.py
+
+# Check tool name parity
+echo "🔧 Checking tool name parity..."
+python3 ci_check_tool_names.py
+
+# Check entrypoint sanity
+echo "🚀 Checking entrypoint sanity..."
+python3 ci_check_entrypoints.py
+
+# Validate registry format
+echo "📊 Validating registry format..."
+python3 -c "
+import json
+with open('capability_registry_v2.json', 'r') as f:
+ registry = json.load(f)
+
+# Required sections
+required_sections = ['mcp_servers', 'terraform_resources', 'gitops_tools', 'security_framework', 'operational_tools']
+for section in required_sections:
+ assert section in registry, f'Missing section: {section}'
+
+# MCP server validation
+for server_name, server_info in registry['mcp_servers'].items():
+ assert 'entrypoint' in server_info, f'Missing entrypoint for {server_name}'
+ assert 'tools' in server_info, f'Missing tools for {server_name}'
+ assert 'auth_env' in server_info, f'Missing auth_env for {server_name}'
+ assert 'side_effects' in server_info, f'Missing side_effects for {server_name}'
+ assert 'outputs' in server_info, f'Missing outputs for {server_name}'
+
+print('✅ Registry format validation passed')
+"
+
+# Check for changes from original
+echo "📈 Checking for registry changes..."
+if git diff --quiet capability_registry_v2.json; then
+ echo "✅ Registry is stable - no changes detected"
+else
+ echo "⚠️ Registry changed during validation"
+ git diff capability_registry_v2.json
+ echo "💡 Consider committing these changes"
+fi
+
+echo ""
+echo "🎉 Registry validation completed successfully!"
+echo "💡 Run this script before committing Cloudflare changes"
\ No newline at end of file
diff --git a/waf_intel_mcp.py b/waf_intel_mcp.py
index 73b184f..f3e8de0 100755
--- a/waf_intel_mcp.py
+++ b/waf_intel_mcp.py
@@ -1,110 +1,15 @@
#!/usr/bin/env python3
from __future__ import annotations
-import glob
-from dataclasses import asdict
-from typing import Any, Dict, List
+"""
+WAF Intelligence MCP Server entrypoint.
-from modelcontextprotocol.python import Server
-from mcp.waf_intelligence.orchestrator import WAFInsight, WAFIntelligence
-from layer0 import layer0_entry
-from layer0.shadow_classifier import ShadowEvalResult
+This wrapper intentionally avoids third-party MCP SDK dependencies and delegates to the
+in-repo stdio JSON-RPC implementation at `mcp.waf_intelligence.mcp_server`.
+"""
-server = Server("waf_intel")
-
-
-def _insight_to_dict(insight: WAFInsight) -> Dict[str, Any]:
- """Convert a WAFInsight dataclass into a plain dict."""
- return asdict(insight)
-
-
-@server.tool()
-async def analyze_waf(
- file: str | None = None,
- files: List[str] | None = None,
- limit: int = 3,
- severity_threshold: str = "warning",
-) -> Dict[str, Any]:
- """
- Analyze one or more Terraform WAF files and return curated insights.
-
- Args:
- file: Single file path (e.g. "terraform/waf.tf").
- files: Optional list of file paths or glob patterns (e.g. ["terraform/waf*.tf"]).
- limit: Max number of high-priority insights to return.
- severity_threshold: Minimum severity to include ("info", "warning", "error").
-
- Returns:
- {
- "results": [
- {
- "file": "...",
- "insights": [ ... ]
- },
- ...
- ]
- }
- """
- routing_action, shadow = layer0_entry(_shadow_repr(file, files, limit, severity_threshold))
- if routing_action != "HANDOFF_TO_LAYER1":
- _raise_layer0(routing_action, shadow)
-
- paths: List[str] = []
-
- if files:
- for pattern in files:
- for matched in glob.glob(pattern):
- paths.append(matched)
-
- if file:
- paths.append(file)
-
- seen = set()
- unique_paths: List[str] = []
- for p in paths:
- if p not in seen:
- seen.add(p)
- unique_paths.append(p)
-
- if not unique_paths:
- raise ValueError("Please provide 'file' or 'files' to analyze.")
-
- intel = WAFIntelligence()
- results: List[Dict[str, Any]] = []
-
- for path in unique_paths:
- insights: List[WAFInsight] = intel.analyze_and_recommend(
- path,
- limit=limit,
- min_severity=severity_threshold,
- )
- results.append(
- {
- "file": path,
- "insights": [_insight_to_dict(insight) for insight in insights],
- }
- )
-
- return {"results": results}
+from cloudflare.mcp.waf_intelligence.mcp_server import main
if __name__ == "__main__":
- server.run()
-
-
-def _shadow_repr(file: str | None, files: List[str] | None, limit: int, severity: str) -> str:
- try:
- return f"analyze_waf: file={file}, files={files}, limit={limit}, severity={severity}"
- except Exception:
- return "analyze_waf"
-
-
-def _raise_layer0(routing_action: str, shadow: ShadowEvalResult) -> None:
- if routing_action == "FAIL_CLOSED":
- raise ValueError("Layer 0: cannot comply with this request.")
- if routing_action == "HANDOFF_TO_GUARDRAILS":
- reason = shadow.reason or "governance_violation"
- raise ValueError(f"Layer 0: governance violation detected ({reason}).")
- if routing_action == "PROMPT_FOR_CLARIFICATION":
- raise ValueError("Layer 0: request is ambiguous. Please clarify and retry.")
- raise ValueError("Layer 0: unrecognized routing action; refusing request.")
+ main()