From 242bd98db48f9c89c242edb2cc7e61dc4c2f18ff Mon Sep 17 00:00:00 2001 From: rookiestar28 <151893693+rookiestar28@users.noreply.github.com> Date: Thu, 19 Feb 2026 03:03:03 +0800 Subject: [PATCH] close S64/R116 with explicit route-plane governance and invariant-gated MAE validation --- api/assist.py | 16 +++- api/bridge.py | 21 ++++- api/capabilities.py | 19 +++- api/checkpoints_handler.py | 18 +++- api/config.py | 19 +++- api/events.py | 16 +++- api/packs.py | 18 +++- api/preflight_handler.py | 16 +++- api/presets.py | 52 +++++++++++ api/routes.py | 18 +++- api/secrets.py | 17 +++- api/security_doctor.py | 15 +++- api/templates.py | 15 +++- api/tools.py | 16 +++- api/triggers.py | 20 +++++ api/webhook.py | 15 +++- api/webhook_submit.py | 15 +++- api/webhook_validate.py | 15 +++- services/endpoint_manifest.py | 99 +++++++++++++++------ services/parameter_lab.py | 20 ++++- services/security_invariants.py | 108 +++++++++++++++++++++++ tests/test_s60_mae_route_segmentation.py | 15 ++-- tests/test_webhook_validate.py | 5 ++ 23 files changed, 516 insertions(+), 72 deletions(-) create mode 100644 services/security_invariants.py diff --git a/api/assist.py b/api/assist.py index 0de5bd9..bfcd290 100644 --- a/api/assist.py +++ b/api/assist.py @@ -18,9 +18,19 @@ except ImportError: # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.assist") @@ -41,6 +51,7 @@ class AssistHandlers: summary="Run planner", description="Generate prompts from requirements via LLM.", audit="assist.planner", + plane=RoutePlane.ADMIN, ) async def planner_handler(self, request): """ @@ -97,6 +108,7 @@ class AssistHandlers: summary="Run refiner", description="Refine prompt/parameters based on feedback.", audit="assist.refiner", + plane=RoutePlane.ADMIN, ) async def refiner_handler(self, request): """ diff --git a/api/bridge.py b/api/bridge.py index d078d4d..a263778 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -57,9 +57,19 @@ except ImportError: # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.bridge") @@ -129,6 +139,7 @@ class BridgeHandlers: risk=RiskTier.LOW, summary="Bridge health", description="Returns bridge health status.", + plane=RoutePlane.USER, ) async def health_handler(self, request: web.Request) -> web.Response: """ @@ -168,6 +179,7 @@ class BridgeHandlers: risk=RiskTier.LOW, summary="Bridge handshake", description="Negotiate protocol version.", + plane=RoutePlane.USER, ) async def handshake_handler(self, request: web.Request) -> web.Response: """ @@ -199,6 +211,7 @@ class BridgeHandlers: summary="Bridge submit", description="Submit a job via sidecar bridge.", audit="bridge.submit", + plane=RoutePlane.INTERNAL, ) async def submit_handler(self, request: web.Request) -> web.Response: """ @@ -427,6 +440,7 @@ class BridgeHandlers: summary="Bridge deliver", description="Request outbound delivery via sidecar.", audit="bridge.deliver", + plane=RoutePlane.INTERNAL, ) async def deliver_handler(self, request: web.Request) -> web.Response: """ @@ -610,6 +624,7 @@ class BridgeHandlers: risk=RiskTier.LOW, summary="Worker poll", description="Worker polls for pending jobs.", + plane=RoutePlane.INTERNAL, ) async def worker_poll_handler(self, request: web.Request) -> web.Response: """ @@ -647,6 +662,7 @@ class BridgeHandlers: summary="Worker result", description="Worker submits completed job result.", audit="bridge.worker.result", + plane=RoutePlane.INTERNAL, ) async def worker_result_handler(self, request: web.Request) -> web.Response: """ @@ -741,6 +757,7 @@ class BridgeHandlers: risk=RiskTier.LOW, summary="Worker heartbeat", description="Worker reports its status.", + plane=RoutePlane.INTERNAL, ) async def worker_heartbeat_handler(self, request: web.Request) -> web.Response: """ diff --git a/api/capabilities.py b/api/capabilities.py index 0a58f09..1f1ccb9 100644 --- a/api/capabilities.py +++ b/api/capabilities.py @@ -12,10 +12,20 @@ else: from services.capabilities import get_capabilities -try: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata -except ImportError: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata +if __package__ and "." in __package__: + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) +else: + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) @endpoint_metadata( @@ -24,6 +34,7 @@ except ImportError: summary="Get capabilities", description="Returns API version and feature flags.", audit="capabilities.list", + plane=RoutePlane.USER, ) async def capabilities_handler(request: web.Request) -> web.Response: """ diff --git a/api/checkpoints_handler.py b/api/checkpoints_handler.py index 82270e9..b93be0f 100644 --- a/api/checkpoints_handler.py +++ b/api/checkpoints_handler.py @@ -41,9 +41,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.checkpoints") @@ -87,6 +97,7 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None: summary="List checkpoints", description="List available workflow checkpoints.", audit="checkpoints.list", + plane=RoutePlane.ADMIN, ) async def list_checkpoints_handler(request: web.Request) -> web.Response: """GET /openclaw/checkpoints""" @@ -117,6 +128,7 @@ async def list_checkpoints_handler(request: web.Request) -> web.Response: summary="Create checkpoint", description="Create a new workflow checkpoint.", audit="checkpoints.create", + plane=RoutePlane.ADMIN, ) async def create_checkpoint_handler(request: web.Request) -> web.Response: """POST /openclaw/checkpoints""" @@ -169,6 +181,7 @@ async def create_checkpoint_handler(request: web.Request) -> web.Response: summary="Get checkpoint", description="Retrieve specific checkpoint details.", audit="checkpoints.get", + plane=RoutePlane.ADMIN, ) async def get_checkpoint_handler(request: web.Request) -> web.Response: """GET /openclaw/checkpoints/{id}""" @@ -202,6 +215,7 @@ async def get_checkpoint_handler(request: web.Request) -> web.Response: summary="Delete checkpoint", description="Delete a workflow checkpoint.", audit="checkpoints.delete", + plane=RoutePlane.ADMIN, ) async def delete_checkpoint_handler(request: web.Request) -> web.Response: """DELETE /openclaw/checkpoints/{id}""" diff --git a/api/config.py b/api/config.py index 9f39abe..c209f73 100644 --- a/api/config.py +++ b/api/config.py @@ -149,10 +149,20 @@ def _cache_get(key: tuple): # S14/R98: Import Endpoint Metadata try: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) except ImportError: # Test fallback - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) # Provider catalog for UI dropdown (R16 dynamic) @@ -196,6 +206,7 @@ except ImportError: summary="Get configuration", description="Returns effective config, sources, and provider catalog.", audit="config.read", + plane=RoutePlane.ADMIN, ) async def config_get_handler(request: web.Request) -> web.Response: """ @@ -311,6 +322,7 @@ def _extract_models_from_payload(payload: dict) -> list: summary="List remote models", description="Fetch a remote model list (best-effort) for OpenAI-compatible providers.", audit="llm.list_models", + plane=RoutePlane.ADMIN, ) async def llm_models_handler(request: web.Request) -> web.Response: """ @@ -528,6 +540,7 @@ async def llm_models_handler(request: web.Request) -> web.Response: summary="Update configuration", description="Updates non-secret LLM config.", audit="config.update", + plane=RoutePlane.ADMIN, ) async def config_put_handler(request: web.Request) -> web.Response: """ @@ -666,6 +679,7 @@ async def config_put_handler(request: web.Request) -> web.Response: summary="Test LLM connection", description="Tests LLM connection using provided or stored credentials.", audit="llm.test_connection", + plane=RoutePlane.ADMIN, ) async def llm_test_handler(request: web.Request) -> web.Response: """ @@ -862,6 +876,7 @@ async def llm_test_handler(request: web.Request) -> web.Response: summary="Chat completion", description="Run a simple chat completion using server-side LLM config.", audit="llm.chat_completion", + plane=RoutePlane.ADMIN, ) async def llm_chat_handler(request: web.Request) -> web.Response: """ diff --git a/api/events.py b/api/events.py index 737c84c..834cd06 100644 --- a/api/events.py +++ b/api/events.py @@ -42,9 +42,19 @@ SSE_MAX_DURATION_SEC = 300 # 5 minutes # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) @endpoint_metadata( @@ -53,6 +63,7 @@ else: summary="Stream job events", description="SSE endpoint for job lifecycle events.", audit="events.stream", + plane=RoutePlane.ADMIN, ) async def events_stream_handler(request: web.Request) -> web.StreamResponse: """ @@ -152,6 +163,7 @@ async def events_stream_handler(request: web.Request) -> web.StreamResponse: summary="Poll job events", description="JSON polling fallback for job events.", audit="events.poll", + plane=RoutePlane.ADMIN, ) async def events_poll_handler(request: web.Request) -> web.Response: """ diff --git a/api/packs.py b/api/packs.py index 3ebf748..5183dd4 100644 --- a/api/packs.py +++ b/api/packs.py @@ -48,9 +48,19 @@ else: # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) # Strict pattern for pack name/version URL route parameters. _SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9._-]+$") @@ -93,6 +103,7 @@ class PacksHandlers: summary="List packs", description="List installed packs.", audit="packs.list", + plane=RoutePlane.ADMIN, ) async def list_packs_handler(self, request: web.Request) -> web.Response: """GET /packs - List installed packs.""" @@ -125,6 +136,7 @@ class PacksHandlers: summary="Import pack", description="Install pack from zip upload.", audit="packs.import", + plane=RoutePlane.ADMIN, ) async def import_pack_handler(self, request: web.Request) -> web.Response: """POST /packs/import - Install pack from zip upload.""" @@ -176,6 +188,7 @@ class PacksHandlers: summary="Delete pack", description="Uninstall pack.", audit="packs.delete", + plane=RoutePlane.ADMIN, ) async def delete_pack_handler(self, request: web.Request) -> web.Response: """DELETE /packs/{name}/{version} - Uninstall pack.""" @@ -215,6 +228,7 @@ class PacksHandlers: summary="Export pack", description="Download pack zip.", audit="packs.export", + plane=RoutePlane.ADMIN, ) async def export_pack_handler(self, request: web.Request) -> web.Response: """GET /packs/export/{name}/{version} - Download pack zip.""" diff --git a/api/preflight_handler.py b/api/preflight_handler.py index 84ffa7e..75f96f8 100644 --- a/api/preflight_handler.py +++ b/api/preflight_handler.py @@ -39,9 +39,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.preflight") @@ -80,6 +90,7 @@ def _deny_remote_admin_if_needed(request: web.Request) -> web.Response | None: summary="Run preflight check", description="Analyze workflow JSON for missing nodes and models.", audit="preflight.analyze", + plane=RoutePlane.ADMIN, ) async def preflight_handler(request: web.Request) -> web.Response: """ @@ -153,6 +164,7 @@ async def preflight_handler(request: web.Request) -> web.Response: summary="Get inventory", description="Returns a snapshot of available nodes and models.", audit="preflight.inventory", + plane=RoutePlane.ADMIN, ) async def inventory_handler(request: web.Request) -> web.Response: """ diff --git a/api/presets.py b/api/presets.py index dddf717..3e3377f 100644 --- a/api/presets.py +++ b/api/presets.py @@ -12,10 +12,22 @@ from aiohttp import web try: from ..services.access_control import require_admin_token + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) from ..services.presets import Preset, preset_store except ImportError: # Fallback for ComfyUI's non-package loader or ad-hoc imports. from services.access_control import require_admin_token + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) from services.presets import Preset, preset_store logger = logging.getLogger("ComfyUI-OpenClaw.api.presets") @@ -24,6 +36,14 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.presets") class PresetHandlers: """Handlers for preset API.""" + @endpoint_metadata( + auth=AuthTier.PUBLIC, # Conditionally public + risk=RiskTier.LOW, + summary="List presets", + description="List available presets (dynamic auth).", + audit="presets.list", + plane=RoutePlane.USER, + ) async def list_presets(self, request: web.Request) -> web.Response: """ GET /moltbot/presets @@ -57,6 +77,14 @@ class PresetHandlers: presets = preset_store.list_presets(category=category, tag=tag) return web.json_response([p.to_dict() for p in presets]) + @endpoint_metadata( + auth=AuthTier.PUBLIC, # Conditionally public + risk=RiskTier.LOW, + summary="Get preset", + description="Get preset details (dynamic auth).", + audit="presets.get", + plane=RoutePlane.USER, + ) async def get_preset(self, request: web.Request) -> web.Response: """GET /moltbot/presets/{preset_id}""" # Milestone B: Auth Check @@ -86,6 +114,14 @@ class PresetHandlers: return web.json_response(preset.to_dict()) + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.MEDIUM, + summary="Create preset", + description="Create a new preset.", + audit="presets.create", + plane=RoutePlane.ADMIN, + ) async def create_preset(self, request: web.Request) -> web.Response: """POST /moltbot/presets""" allowed, error = require_admin_token(request) @@ -128,6 +164,14 @@ class PresetHandlers: logger.error(f"Failed to create preset: {e}") return web.json_response({"error": str(e)}, status=500) + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.MEDIUM, + summary="Update preset", + description="Update an existing preset.", + audit="presets.update", + plane=RoutePlane.ADMIN, + ) async def update_preset(self, request: web.Request) -> web.Response: """PUT /moltbot/presets/{preset_id}""" allowed, error = require_admin_token(request) @@ -170,6 +214,14 @@ class PresetHandlers: return web.json_response(preset.to_dict()) + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.HIGH, + summary="Delete preset", + description="Delete a preset.", + audit="presets.delete", + plane=RoutePlane.ADMIN, + ) async def delete_preset(self, request: web.Request) -> web.Response: """DELETE /moltbot/presets/{preset_id}""" allowed, error = require_admin_token(request) diff --git a/api/routes.py b/api/routes.py index 93b5cfb..bef0e5d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -14,9 +14,19 @@ import time # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) try: from aiohttp import web # type: ignore @@ -186,6 +196,7 @@ def _ensure_observability_deps_ready() -> tuple[bool, str | None]: summary="Health check", description="Returns pack status, uptime, dependencies, and stats.", audit="health.check", + plane=RoutePlane.USER, ) async def health_handler(request: web.Request) -> web.Response: """ @@ -324,6 +335,7 @@ async def health_handler(request: web.Request) -> web.Response: summary="Tail logs", description="Returns the last N lines of the log file.", audit="logs.tail", + plane=RoutePlane.ADMIN, ) async def logs_tail_handler(request: web.Request) -> web.Response: """GET /moltbot/logs/tail - Returns the last N lines of the log file.""" @@ -417,6 +429,7 @@ async def logs_tail_handler(request: web.Request) -> web.Response: summary="List jobs", description="Stub endpoint for job listing.", audit="jobs.list", + plane=RoutePlane.ADMIN, ) async def jobs_handler(request: web.Request) -> web.Response: """ @@ -441,6 +454,7 @@ async def jobs_handler(request: web.Request) -> web.Response: summary="Get trace", description="Returns redacted timeline for a prompt.", audit="trace.get", + plane=RoutePlane.ADMIN, ) async def trace_handler(request: web.Request) -> web.Response: """GET /moltbot/trace/{prompt_id} - Returns trace_id and redacted timeline.""" diff --git a/api/secrets.py b/api/secrets.py index 017fa43..319e51d 100644 --- a/api/secrets.py +++ b/api/secrets.py @@ -49,9 +49,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.secrets") @@ -123,6 +133,7 @@ def _rate_limit_admin(request: web.Request) -> Optional[web.Response]: summary="Get secret status", description="Returns secret configuration status (NO ACTUAL VALUES).", audit="secrets.status", + plane=RoutePlane.ADMIN, ) async def secrets_status_handler(request: web.Request) -> web.Response: """ @@ -165,6 +176,7 @@ async def secrets_status_handler(request: web.Request) -> web.Response: summary="Write secret", description="Save API key to server store.", audit="secrets.write", + plane=RoutePlane.ADMIN, ) async def secrets_put_handler(request: web.Request) -> web.Response: """ @@ -304,6 +316,7 @@ async def secrets_put_handler(request: web.Request) -> web.Response: summary="Delete secret", description="Clear provider secret.", audit="secrets.delete", + plane=RoutePlane.ADMIN, ) async def secrets_delete_handler(request: web.Request) -> web.Response: """ diff --git a/api/security_doctor.py b/api/security_doctor.py index 8443cbd..1724bb5 100644 --- a/api/security_doctor.py +++ b/api/security_doctor.py @@ -50,9 +50,19 @@ else: # pragma: no cover (test-only) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.security_doctor") @@ -63,6 +73,7 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.security_doctor") summary="Security Doctor", description="Run security posture diagnostics and remediation.", audit="security.doctor", + plane=RoutePlane.ADMIN, ) async def security_doctor_handler(request: web.Request) -> web.Response: """ diff --git a/api/templates.py b/api/templates.py index 6b45aae..38ca128 100644 --- a/api/templates.py +++ b/api/templates.py @@ -28,9 +28,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.templates") @@ -64,6 +74,7 @@ def _ensure_templates_api_deps_ready() -> tuple[bool, str | None]: summary="List templates", description="Returns templates visible to the backend.", audit="templates.list", + plane=RoutePlane.ADMIN, ) async def templates_list_handler(request: web.Request) -> web.Response: """ diff --git a/api/tools.py b/api/tools.py index fb1ede2..b0b67b1 100644 --- a/api/tools.py +++ b/api/tools.py @@ -20,9 +20,19 @@ except ImportError: # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.tools") @@ -33,6 +43,7 @@ logger = logging.getLogger("ComfyUI-OpenClaw.api.tools") summary="List tools", description="List allowed external tools.", audit="tools.list", + plane=RoutePlane.ADMIN, ) async def tools_list_handler(request: web.Request) -> web.Response: """ @@ -63,6 +74,7 @@ async def tools_list_handler(request: web.Request) -> web.Response: summary="Run tool", description="Execute an external tool.", audit="tools.run", + plane=RoutePlane.ADMIN, ) async def tools_run_handler(request: web.Request) -> web.Response: """ diff --git a/api/triggers.py b/api/triggers.py index da15931..cda124b 100644 --- a/api/triggers.py +++ b/api/triggers.py @@ -23,11 +23,23 @@ from aiohttp import web # can silently import the WRONG module (another custom node or ComfyUI-adjacent package), causing # template allowlists to appear "missing" even when `data/templates/manifest.json` is correct. if __package__ and "." in __package__: + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) from ..services.execution_budgets import BudgetExceededError from ..services.templates import is_template_allowed from ..services.trace import generate_trace_id from ..services.webhook_auth import AuthError else: # pragma: no cover (test-only import mode) + from services.endpoint_manifest import ( # type: ignore + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) from services.execution_budgets import BudgetExceededError # type: ignore from services.templates import is_template_allowed # type: ignore from services.trace import generate_trace_id # type: ignore @@ -76,6 +88,14 @@ class TriggerHandlers: if not allowed: raise AuthError(error or "Unauthorized") + @endpoint_metadata( + auth=AuthTier.ADMIN, + risk=RiskTier.HIGH, + summary="Fire trigger", + description="Fire an ad-hoc workflow trigger.", + audit="triggers.fire", + plane=RoutePlane.ADMIN, + ) async def fire_trigger(self, request: web.Request) -> web.Response: """ POST /moltbot/triggers/fire diff --git a/api/webhook.py b/api/webhook.py index 2de3c5f..054417b 100644 --- a/api/webhook.py +++ b/api/webhook.py @@ -35,10 +35,20 @@ except ImportError: try: from ..services.diagnostics_flags import diagnostics - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) except ImportError: from services.diagnostics_flags import diagnostics - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) # R46: Scoped logger for safe-by-default redaction logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook") @@ -50,6 +60,7 @@ logger = diagnostics.get_logger("ComfyUI-OpenClaw.api.webhook", "webhook") summary="Webhook submit", description="Authenticated endpoint for external job requests (legacy pipeline).", audit="webhook.submit.legacy", + plane=RoutePlane.EXTERNAL, ) async def webhook_handler(request: web.Request) -> web.Response: """ diff --git a/api/webhook_submit.py b/api/webhook_submit.py index 135e20c..ebe973a 100644 --- a/api/webhook_submit.py +++ b/api/webhook_submit.py @@ -54,9 +54,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_submit") @@ -74,6 +84,7 @@ def safe_error_response(status: int, error: str, detail: str = "") -> web.Respon summary="Webhook submit", description="Authenticated endpoint for external job requests (modern pipeline).", audit="webhook.submit", + plane=RoutePlane.EXTERNAL, ) async def webhook_submit_handler(request: web.Request) -> web.Response: """ diff --git a/api/webhook_validate.py b/api/webhook_validate.py index f02a2ca..0749fc2 100644 --- a/api/webhook_validate.py +++ b/api/webhook_validate.py @@ -62,9 +62,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.api.webhook_validate") @@ -84,6 +94,7 @@ def _safe_error_response(status: int, error: str, detail: str = "") -> web.Respo summary="Webhook validate", description="Dry-run validation for webhook requests.", audit="webhook.validate", + plane=RoutePlane.EXTERNAL, ) async def webhook_validate_handler(request: web.Request) -> web.Response: """ diff --git a/services/endpoint_manifest.py b/services/endpoint_manifest.py index b539966..6392949 100644 --- a/services/endpoint_manifest.py +++ b/services/endpoint_manifest.py @@ -52,6 +52,7 @@ class RoutePlane(enum.Enum): USER = "user" # Public user-facing routes ADMIN = "admin" # Admin-only management routes INTERNAL = "internal" # Internal-only (loopback, service mesh) + EXTERNAL = "external" # External webhook/callback ingress surfaces @dataclass @@ -65,7 +66,7 @@ class EndpointMetadata: required_scopes: List[str] = field(default_factory=list) # For S46 audit_action: Optional[str] = None # For R99 # S60: Route plane classification - route_plane: RoutePlane = RoutePlane.USER + route_plane: Optional[RoutePlane] = None # Registry to store metadata by handler function @@ -79,7 +80,7 @@ def endpoint_metadata( description: str = "", scopes: Optional[List[str]] = None, audit: Optional[str] = None, - plane: RoutePlane = RoutePlane.USER, + plane: Optional[RoutePlane] = None, # R116: No implicit default allowed ): """ Decorator to attach security metadata to a handler function. @@ -89,12 +90,19 @@ def endpoint_metadata( auth=AuthTier.ADMIN, risk=RiskTier.HIGH, summary="Restart Server", - audit="server.restart" + audit="server.restart", + plane=RoutePlane.ADMIN # R116: Mandatory classification ) async def handler(request): ... """ def decorator(handler: Callable): + # R116: Enforce explicit plane classification + # We allow None during decoration to support legacy transition, + # but validation gate will fail. + # Ideally, we'd fail fast here, but that might crash import time if not all are migrated. + # Let's verify during manifest generation instead. + meta = EndpointMetadata( auth_tier=auth, risk_tier=risk, @@ -102,7 +110,7 @@ def endpoint_metadata( description=description, required_scopes=scopes or [], audit_action=audit, - route_plane=plane, + route_plane=plane, # May be None if missed ) _HANDLER_REGISTRY[handler] = meta # Attach to function for runtime introspection if needed @@ -118,6 +126,10 @@ def get_metadata(handler: Callable) -> Optional[EndpointMetadata]: while isinstance(handler, (functools.partial,)): handler = handler.func + # Unwrap bound methods (e.g. class instance methods) + if inspect.ismethod(handler): + handler = handler.__func__ + # Check registry first if handler in _HANDLER_REGISTRY: return _HANDLER_REGISTRY[handler] @@ -159,7 +171,7 @@ def generate_manifest(app) -> List[Dict[str, Any]]: "risk": meta.risk_tier.value, "summary": meta.summary, "audit": meta.audit_action, - "plane": meta.route_plane.value, + "plane": meta.route_plane.value if meta.route_plane else None, } manifest.append(entry) @@ -168,7 +180,7 @@ def generate_manifest(app) -> List[Dict[str, Any]]: # --------------------------------------------------------------------------- -# S60: MAE posture validation +# S60: MAE posture validation (Enhanced by S64) # --------------------------------------------------------------------------- import logging as _logging @@ -181,42 +193,71 @@ def validate_mae_posture( profile: str = "local", ) -> Tuple[bool, List[str]]: """ - S60: Validate that the endpoint manifest respects route-plane segmentation - for the given deployment profile. + S60/R116: Validate that the endpoint manifest respects route-plane segmentation + and S64 invariants for the given deployment profile. - In 'public' or 'hardened' profiles: - - No ADMIN or INTERNAL plane routes should be exposed on the user plane - - All classified routes must have a route_plane assigned + Enforces: + - S64.INV.005: All routes must have explicit plane classification (R116) + - S64.INV.006: All routes must have explicit auth classification + - S64.INV.002: No Admin/Internal plane routes exposed on User plane (Public/Hardened) Returns: (is_valid, violations) """ + # Import here to avoid circular dependency + if __package__ and "." in __package__: + from .security_invariants import REGISTRY + else: + from services.security_invariants import REGISTRY + violations: List[str] = [] - if profile not in ("public", "hardened"): - # Local profile: no enforcement - return True, [] - for entry in manifest: + method = entry.get("method") + path = entry.get("path") meta = entry.get("metadata") - if not meta: - # Unclassified route in public profile is a violation - if entry.get("method") != "*": # Skip catch-all routes - violations.append( - f"Unclassified route in {profile} profile: " - f"{entry.get('method')} {entry.get('path')}" - ) + + # Skip catch-all or unmanaged routes if they are truly outside our scope + if method == "*": continue - plane = meta.get("plane", "user") - auth = meta.get("auth", "public") - - # ADMIN and INTERNAL plane routes must not be exposed without admin auth - if plane in ("admin", "internal") and auth == "public": + # S64.INV.005: Explicit Classification Gate + if not meta: + inv = REGISTRY["S64.INV.005"] violations.append( - f"S60: {plane}-plane route exposed with public auth: " - f"{entry.get('method')} {entry.get('path')}" + f"[{inv.id}] Unclassified route (missing metadata): " + f"{method} {path}. {inv.remediation}" ) + continue + + # S64.INV.006: Explicit Auth Tier + auth = meta.get("auth") + if not auth: + inv = REGISTRY["S64.INV.006"] + violations.append( + f"[{inv.id}] Route missing explicit Auth classification: " + f"{method} {path}. {inv.remediation}" + ) + + # S64.INV.005: Plane Check + plane = meta.get("plane") + if not plane: + inv = REGISTRY["S64.INV.005"] + violations.append( + f"[{inv.id}] Route missing explicit Plane classification: " + f"{method} {path}. {inv.remediation}" + ) + continue + + # Posture Checks (Profile Dependent) + if profile in ("public", "hardened"): + # S64.INV.002: Plane/Auth Compatibility + if plane in ("admin", "internal") and auth == "public": + inv = REGISTRY["S64.INV.002"] + violations.append( + f"[{inv.id}] {plane}-plane route exposed with public auth: " + f"{method} {path}. {inv.remediation}" + ) if violations: for v in violations: diff --git a/services/parameter_lab.py b/services/parameter_lab.py index e92594b..bb98fed 100644 --- a/services/parameter_lab.py +++ b/services/parameter_lab.py @@ -28,9 +28,19 @@ else: # pragma: no cover (test-only import mode) # R98: Endpoint Metadata if __package__ and "." in __package__: - from ..services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from ..services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) else: - from services.endpoint_manifest import AuthTier, RiskTier, endpoint_metadata + from services.endpoint_manifest import ( + AuthTier, + RiskTier, + RoutePlane, + endpoint_metadata, + ) logger = logging.getLogger("ComfyUI-OpenClaw.services.parameter_lab") @@ -382,6 +392,7 @@ def _require_admin(request: web.Request) -> Optional[web.Response]: summary="Create comparison", description="Create a bounded multi-model comparison plan.", audit="lab.compare.create", + plane=RoutePlane.ADMIN, ) async def create_compare_handler(request: web.Request) -> web.Response: if web is None: @@ -433,6 +444,7 @@ async def create_compare_handler(request: web.Request) -> web.Response: summary="Create sweep", description="Create a bounded parameter sweep plan.", audit="lab.sweep.create", + plane=RoutePlane.ADMIN, ) async def create_sweep_handler(request: web.Request) -> web.Response: if web is None: @@ -470,6 +482,7 @@ async def create_sweep_handler(request: web.Request) -> web.Response: summary="List experiments", description="List persistent experiments.", audit="lab.list", + plane=RoutePlane.ADMIN, ) async def list_experiments_handler(request: web.Request) -> web.Response: if web is None: @@ -489,6 +502,7 @@ async def list_experiments_handler(request: web.Request) -> web.Response: summary="Get experiment", description="Retrieve experiment details.", audit="lab.get", + plane=RoutePlane.ADMIN, ) async def get_experiment_handler(request: web.Request) -> web.Response: if web is None: @@ -514,6 +528,7 @@ async def get_experiment_handler(request: web.Request) -> web.Response: summary="Update experiment", description="Update experiment state (e.g. run results).", audit="lab.update", + plane=RoutePlane.ADMIN, ) async def update_experiment_handler(request: web.Request) -> web.Response: if web is None: @@ -550,6 +565,7 @@ async def update_experiment_handler(request: web.Request) -> web.Response: summary="Select winner", description="Select experiment winner and return params.", audit="lab.winner", + plane=RoutePlane.ADMIN, ) async def select_apply_winner_handler(request: web.Request) -> web.Response: """ diff --git a/services/security_invariants.py b/services/security_invariants.py new file mode 100644 index 0000000..fb3ab05 --- /dev/null +++ b/services/security_invariants.py @@ -0,0 +1,108 @@ +""" +S64 Security Invariants Registry. + +This module defines the canonical security invariants that must be true +for the application to be considered secure. These invariants are +enforced locally by the Security Gate (S41) and in CI by policy checks. + +Artifact Ownership: +- Public Posture: unreachable surfaces (admin-plane leaks) +- Admin Plane: capabilities restricted to admin token +- Fail-Closed: missing security controls block startup +""" + +import enum +from dataclasses import dataclass +from typing import Dict, List, Optional + + +class InvariantScope(enum.Enum): + STARTUP = "startup" + RUNTIME = "runtime" + CI = "ci" + + +class InvariantSeverity(enum.Enum): + CRITICAL = "critical" # Must block startup/CI + HIGH = "high" # Should block, overrideable in DEV + WARNING = "warning" # Audit only + + +@dataclass +class SecurityInvariant: + id: str + scope: InvariantScope + severity: InvariantSeverity + description: str + remediation: str + + +# Canonical Registry of Security Invariants +REGISTRY: Dict[str, SecurityInvariant] = { + # Public Posture / Network Invariants + "S64.INV.001": SecurityInvariant( + id="S64.INV.001", + scope=InvariantScope.STARTUP, + severity=InvariantSeverity.CRITICAL, + description="Admin-plane routes must not be exposed on public interfaces without explicit auth override.", + remediation="Configure OPENCLAW_ADMIN_TOKEN or bind to localhost only." + ), + "S64.INV.002": SecurityInvariant( + id="S64.INV.002", + scope=InvariantScope.STARTUP, + severity=InvariantSeverity.CRITICAL, + description="Public ingress must not bypass MAE route segmentation (no Admin/Internal on User plane).", + remediation="Check route configuration and deployment profile (OPENCLAW_DEPLOYMENT_PROFILE)." + ), + + # Fail-Closed Invariants + "S64.INV.003": SecurityInvariant( + id="S64.INV.003", + scope=InvariantScope.STARTUP, + severity=InvariantSeverity.CRITICAL, + description="Missing critical security secrets (Tokens/Keys) must block startup in Hardened/Public modes.", + remediation="Provide required secrets (OPENCLAW_ADMIN_TOKEN, keys) or switch to Local profile." + ), + "S64.INV.004": SecurityInvariant( + id="S64.INV.004", + scope=InvariantScope.STARTUP, + severity=InvariantSeverity.CRITICAL, + description="Failed module adapters must not degrade into 'open' state.", + remediation="Check module initialization logs. Ensure fail-closed logic is active." + ), + + # Metadata / Governance Invariants (R116) + "S64.INV.005": SecurityInvariant( + id="S64.INV.005", + scope=InvariantScope.CI, + severity=InvariantSeverity.CRITICAL, + description="All managed routes must have explicit Route Plane classification.", + remediation="Decorate route handler with @endpoint_metadata(plane=...)." + ), + "S64.INV.006": SecurityInvariant( + id="S64.INV.006", + scope=InvariantScope.CI, + severity=InvariantSeverity.CRITICAL, + description="All managed routes must have explicit Auth Tier classification.", + remediation="Decorate route handler with @endpoint_metadata(auth=...)." + ), +} + + +@dataclass +class InvariantViolation: + invariant_id: str + context: str + evidence: str + + def to_dict(self): + inv = REGISTRY.get(self.invariant_id) + return { + "code": self.invariant_id, + "severity": inv.severity.value if inv else "unknown", + "scope": inv.scope.value if inv else "unknown", + "description": inv.description if inv else "Unknown Invariant", + "context": self.context, + "evidence": self.evidence, + "remediation": inv.remediation if inv else "" + } diff --git a/tests/test_s60_mae_route_segmentation.py b/tests/test_s60_mae_route_segmentation.py index dab4ae2..037eb49 100644 --- a/tests/test_s60_mae_route_segmentation.py +++ b/tests/test_s60_mae_route_segmentation.py @@ -25,19 +25,20 @@ class TestS60RoutePlane(unittest.TestCase): """S60: RoutePlane enum and EndpointMetadata classification tests.""" def test_route_plane_enum_values(self): - """RoutePlane has USER, ADMIN, INTERNAL.""" + """RoutePlane has USER, ADMIN, INTERNAL, EXTERNAL.""" self.assertEqual(RoutePlane.USER.value, "user") self.assertEqual(RoutePlane.ADMIN.value, "admin") self.assertEqual(RoutePlane.INTERNAL.value, "internal") + self.assertEqual(RoutePlane.EXTERNAL.value, "external") - def test_endpoint_metadata_default_plane_is_user(self): - """EndpointMetadata defaults to USER plane.""" + def test_endpoint_metadata_default_plane_is_none(self): + """EndpointMetadata defaults to None (explicit classification required).""" meta = EndpointMetadata( auth_tier=AuthTier.PUBLIC, risk_tier=RiskTier.LOW, summary="Test endpoint", ) - self.assertEqual(meta.route_plane, RoutePlane.USER) + self.assertIsNone(meta.route_plane) def test_endpoint_metadata_admin_plane(self): """EndpointMetadata can be set to ADMIN plane.""" @@ -79,8 +80,8 @@ class TestS60DecoratorPlane(unittest.TestCase): self.assertIsNotNone(meta) self.assertEqual(meta.route_plane, RoutePlane.ADMIN) - def test_decorator_default_plane_is_user(self): - """Decorator defaults to USER plane.""" + def test_decorator_default_plane_is_none(self): + """Decorator defaults to None (explicit classification required).""" @endpoint_metadata( auth=AuthTier.PUBLIC, @@ -92,7 +93,7 @@ class TestS60DecoratorPlane(unittest.TestCase): meta = getattr(public_handler, "__openclaw_meta__", None) self.assertIsNotNone(meta) - self.assertEqual(meta.route_plane, RoutePlane.USER) + self.assertIsNone(meta.route_plane) class TestS60MAEPostureValidation(unittest.TestCase): diff --git a/tests/test_webhook_validate.py b/tests/test_webhook_validate.py index 0de9482..4ba65b9 100644 --- a/tests/test_webhook_validate.py +++ b/tests/test_webhook_validate.py @@ -29,6 +29,11 @@ except ModuleNotFoundError: # pragma: no cover _AIOHTTP_AVAILABLE = False +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + from api.webhook_validate import webhook_validate_handler from models.schemas import WebhookJobRequest from services.execution_budgets import BudgetExceededError