diff --git a/application/single_app/config.py b/application/single_app/config.py index a8f3ca588..0175d1f6c 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -103,7 +103,7 @@ EXECUTOR_MAX_WORKERS = 30 CHAT_STREAM_EXECUTOR_MAX_WORKERS = max(8, int(os.getenv('SIMPLECHAT_CHAT_STREAM_WORKERS', '32') or '32')) SESSION_TYPE = 'filesystem' -VERSION = "0.261.434" +VERSION = "0.261.436" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_action_connection_tests.py b/application/single_app/functions_action_connection_tests.py index 04ac56106..0dc6fdad9 100644 --- a/application/single_app/functions_action_connection_tests.py +++ b/application/single_app/functions_action_connection_tests.py @@ -20,6 +20,8 @@ import requests from functions_appinsights import log_event +from functions_outbound_destinations import OutboundDestinationError +from functions_outbound_http import guarded_probe_request from functions_azure_maps import ( AZURE_MAPS_DEFAULT_ENDPOINT, AZURE_MAPS_DEFAULT_LANGUAGE, @@ -228,14 +230,20 @@ def test_openapi_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: probe = _build_openapi_probe_request(manifest) try: - response = requests.get( + response = guarded_probe_request( + "GET", base_url, + max_bytes=0, headers=probe["headers"], params=probe["params"] or None, auth=probe["basic_auth"], timeout=timeout, allow_redirects=True, ) + except OutboundDestinationError as exc: + result = build_failure_result(exc.public_message) + _log_connection_test("openapi", result, {"stage": "destination_policy"}) + return result except requests.RequestException as exc: result = build_failure_result( f"The API base URL could not be reached: {sanitize_connection_error(exc, manifest)}", @@ -277,8 +285,10 @@ def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: return build_failure_result("An Azure Maps subscription key is required before testing this action.") try: - response = requests.get( + response = guarded_probe_request( + "GET", f"{endpoint}/map/tile", + max_bytes=0, params={ "api-version": AZURE_MAPS_TILE_API_VERSION, "tilesetId": AZURE_MAPS_DEFAULT_TILESET_ID, @@ -292,6 +302,10 @@ def test_azure_maps_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: }, timeout=ACTION_CONNECTION_TEST_DEFAULT_TIMEOUT_SECONDS, ) + except OutboundDestinationError as exc: + result = build_failure_result(exc.public_message) + _log_connection_test("azure_maps_openlayers", result, {"stage": "destination_policy"}) + return result except requests.RequestException as exc: result = build_failure_result( f"Azure Maps could not be reached: {sanitize_connection_error(exc, manifest)}", @@ -423,11 +437,16 @@ def test_databricks_connection(manifest: Dict[str, Any]) -> Dict[str, Any]: return result try: - response = requests.get( + response = guarded_probe_request( + "GET", f"{plugin.endpoint}/api/2.0/sql/warehouses/{plugin.warehouse_id}", headers=headers, timeout=timeout, ) + except OutboundDestinationError as exc: + result = build_failure_result(exc.public_message) + _log_connection_test("databricks", result, {"stage": "destination_policy"}) + return result except requests.RequestException as exc: result = build_failure_result( f"The Databricks workspace could not be reached: {sanitize_connection_error(exc, manifest)}", diff --git a/application/single_app/functions_mcp_destinations.py b/application/single_app/functions_mcp_destinations.py index 643a25739..247765dde 100644 --- a/application/single_app/functions_mcp_destinations.py +++ b/application/single_app/functions_mcp_destinations.py @@ -18,6 +18,7 @@ session = None from functions_appinsights import log_event +from functions_outbound_destinations import OutboundDestinationError, normalize_outbound_url from functions_action_manifest import ( McpActionOrigin, McpConfigurationError, @@ -709,6 +710,13 @@ def evaluate_mcp_destination_policy( normalized_scope, normalized_scope_id = resolve_mcp_destination_scope( manifest, scope_type, scope_id, origin=origin ) + try: + normalize_outbound_url(descriptor["normalized_endpoint"]) + except OutboundDestinationError as exc: + return { + "allowed": False, "reason": exc.public_message, "descriptor": descriptor, + "matched_pattern": "", "scope_type": normalized_scope, "scope_id": normalized_scope_id, + } policy = policy_config if policy_config is not None else get_mcp_destination_policy_config(user_id=user_id) environment_decision = _evaluate_destination_descriptor_policy( descriptor, normalized_scope, normalized_scope_id, _get_environment_destination_policy_config() diff --git a/application/single_app/functions_mcp_operations.py b/application/single_app/functions_mcp_operations.py index de0c818fa..5363cc77e 100644 --- a/application/single_app/functions_mcp_operations.py +++ b/application/single_app/functions_mcp_operations.py @@ -8,6 +8,7 @@ from jsonschema.exceptions import SchemaError from functions_action_manifest import McpConfigurationError, McpStdioRemovedError +from functions_outbound_destinations import OutboundDestinationError from functions_mcp_fingerprint_metadata import ( MCP_PROMPTS_FIELD, MCP_TOOL_DRIFT_FIELD, @@ -340,6 +341,21 @@ def redact_mcp_error_detail(value): def classify_mcp_exception(exc, operation="mcp"): """Classify MCP exceptions into safe, actionable categories.""" + pending = [exc] + visited = set() + while pending and len(visited) < 32: + candidate = pending.pop() + if id(candidate) in visited: + continue + visited.add(id(candidate)) + if isinstance(candidate, OutboundDestinationError): + return { + "category": "destination_policy", "message": candidate.public_message, + "detail": candidate.public_message, "operation": operation, "retryable": False, + } + pending.extend(getattr(candidate, "exceptions", ())) + if candidate.__cause__ is not None: + pending.append(candidate.__cause__) detail = redact_mcp_error_detail(str(exc)) detail_lower = detail.lower() operation_text = str(operation or "mcp").strip() or "mcp" @@ -401,7 +417,7 @@ def classify_mcp_exception(exc, operation="mcp"): def get_mcp_error_http_status(category): """Map an MCP error category to an HTTP status suitable for discovery responses.""" - if category in {"validation", "mcp_stdio_removed"}: + if category in {"validation", "mcp_stdio_removed", "destination_policy"}: return 400 if category == "authorization": return 403 diff --git a/application/single_app/functions_outbound_destinations.py b/application/single_app/functions_outbound_destinations.py new file mode 100644 index 000000000..c756c3687 --- /dev/null +++ b/application/single_app/functions_outbound_destinations.py @@ -0,0 +1,216 @@ +# functions_outbound_destinations.py +"""Bootstrap-free destination rules for user-configurable action connections.""" + +import ipaddress +import re +import socket +from dataclasses import dataclass +from urllib.parse import urljoin, urlsplit, urlunsplit + +from functions_action_manifest import resolve_action_type + + +_SCHEME_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443} +_METADATA_ADDRESSES = frozenset({ + ipaddress.ip_address("168.63.129.16"), + ipaddress.ip_address("100.100.100.200"), + ipaddress.ip_address("fd00:ec2::254"), +}) +_THIS_NETWORK = ipaddress.ip_network("0.0.0.0/8") +_NAT64 = ipaddress.ip_network("64:ff9b::/96") +_HOST_LABEL = re.compile(r"[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?") +_NUMERIC_HOST = re.compile(r"(?:[0-9.]+|0x[0-9a-f]+)", re.IGNORECASE) +_HTTP_ACTION_TYPES = { + "openapi", "rocksdb", "yamcs", "tableau", "databricks", "azure_maps_openlayers", "azure_function", "mcp", +} + + +class OutboundDestinationError(ValueError): + """A stable, non-secret action connection policy error.""" + + def __init__(self, message): + super().__init__(message) + self.public_message = message + + +@dataclass(frozen=True) +class OutboundTarget: + url: str + scheme: str + host: str + port: int + + @property + def origin(self): + return self.scheme, self.host, self.port + + +def validate_outbound_address(value): + """Allow enterprise private routing, but never local/infrastructure targets.""" + try: + address = ipaddress.ip_address(value) + except ValueError as exc: + raise OutboundDestinationError("The action destination has an invalid IP address.") from exc + + if address in _METADATA_ADDRESSES: + raise OutboundDestinationError("Cloud infrastructure destinations are not permitted for actions.") + if isinstance(address, ipaddress.IPv6Address): + embedded = address.ipv4_mapped or address.sixtofour + if address in _NAT64: + embedded = ipaddress.IPv4Address(int(address) & 0xFFFFFFFF) + if address.teredo: + for teredo_address in address.teredo: + validate_outbound_address(str(teredo_address)) + if embedded is not None: + validate_outbound_address(str(embedded)) + return + if ( + address.is_loopback or address.is_link_local or address.is_unspecified + or address.is_multicast or address.is_reserved + or (isinstance(address, ipaddress.IPv4Address) and address in _THIS_NETWORK) + ): + raise OutboundDestinationError( + "Localhost, loopback, link-local and reserved destinations are not permitted for actions." + ) + + +def _normalize_host(value): + host = value.lower() + if host.endswith("..") or "%" in host: + raise OutboundDestinationError("The action destination has an invalid hostname.") + host = host.removesuffix(".") + if host == "localhost" or host.endswith(".localhost") or host == "localhost.localdomain": + raise OutboundDestinationError("Localhost destinations are not permitted for actions.") + try: + address = ipaddress.ip_address(host) + except ValueError: + if _NUMERIC_HOST.fullmatch(host): + raise OutboundDestinationError("Noncanonical numeric action destinations are not permitted.") + try: + host = host.encode("idna").decode("ascii") + except UnicodeError as exc: + raise OutboundDestinationError("The action destination has an invalid hostname.") from exc + if len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")): + raise OutboundDestinationError("The action destination has an invalid hostname.") + else: + validate_outbound_address(str(address)) + host = str(address) + return host + + +def normalize_outbound_url(value): + """Validate URL structure and literal destinations without performing DNS.""" + if not isinstance(value, str) or not value.strip() or len(value) > 8192: + raise OutboundDestinationError("An HTTP or remote MCP service URL is required.") + value = value.strip() + if "\\" in value or any(ord(character) <= 32 or ord(character) == 127 for character in value): + raise OutboundDestinationError("The action destination URL is invalid.") + try: + parsed = urlsplit(value) + if ( + parsed.scheme.lower() not in _SCHEME_PORTS or not parsed.hostname + or parsed.username is not None or parsed.password is not None + ): + raise OutboundDestinationError("The action destination URL or scheme is not permitted.") + host = _normalize_host(parsed.hostname) + scheme = parsed.scheme.lower() + port = parsed.port if parsed.port is not None else _SCHEME_PORTS[scheme] + except ValueError as exc: + if isinstance(exc, OutboundDestinationError): + raise + raise OutboundDestinationError("The action destination URL is invalid.") from exc + if not 1 <= port <= 65535: + raise OutboundDestinationError("The action destination port is invalid.") + authority = f"[{host}]" if ":" in host else host + if port != _SCHEME_PORTS[scheme]: + authority = f"{authority}:{port}" + return OutboundTarget( + urlunsplit((scheme, authority, parsed.path or "/", parsed.query, "")), scheme, host, port, + ) + + +def resolve_outbound_addresses(host, port): + """Resolve all candidates once; callers must connect to a returned numeric IP.""" + host = _normalize_host(host) + try: + addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except OSError as exc: + raise OutboundDestinationError("The action destination could not be resolved.") from exc + result = [] + for family, _, _, _, sockaddr in addresses: + if family not in (socket.AF_INET, socket.AF_INET6): + raise OutboundDestinationError("The action destination address family is not supported.") + address = sockaddr[0] + validate_outbound_address(address) + if address not in result: + result.append(address) + if not result: + raise OutboundDestinationError("The action destination did not resolve to a usable address.") + return tuple(result) + + +def validate_outbound_redirect(source, location): + """Permit same-origin redirects and default-port TLS upgrades, never retargeting.""" + before = normalize_outbound_url(source) + after = normalize_outbound_url(urljoin(before.url, location)) + tls_upgrade = ( + before.scheme in {"http", "ws"} and after.scheme == f"{before.scheme}s" + and before.host == after.host and before.port == 80 and after.port == 443 + ) + if before.origin != after.origin and not tls_upgrade: + raise OutboundDestinationError( + "Cross-origin action redirects are not permitted. Configure the final service URL." + ) + return after.url + + +def validate_outbound_headers(url, headers): + target = normalize_outbound_url(str(url)) + authority = f"[{target.host}]" if ":" in target.host else target.host + if target.port != _SCHEME_PORTS[target.scheme]: + authority = f"{authority}:{target.port}" + for name, value in (headers or {}).items(): + if name.lower() == "host": + if any(character in str(value) for character in "/?#@\\"): + raise OutboundDestinationError("Action headers cannot override the destination authority.") + supplied = normalize_outbound_url(f"{target.scheme}://{value}") + if supplied.origin != target.origin or supplied.url != f"{target.scheme}://{authority}/": + raise OutboundDestinationError("Action headers cannot override the destination authority.") + if name.lower() == "proxy-authorization": + raise OutboundDestinationError("Forward-proxy credentials cannot be supplied as action headers.") + return target + + +def action_connection_signature(manifest): + """Compare the effective connector and origin without resolving DNS or secrets.""" + plugin_type = resolve_action_type(manifest) + fields = manifest.get("additionalFields", {}) + if not isinstance(fields, dict): + raise OutboundDestinationError("Action additional fields must be an object.") + endpoint = manifest.get("endpoint") + if plugin_type == "rocksdb": + endpoint = fields.get("base_url") or endpoint + elif plugin_type == "openapi": + endpoint = manifest.get("base_url") or endpoint or fields.get("base_url") + elif plugin_type in {"yamcs", "tableau"}: + endpoint = endpoint or fields.get("server_url") or fields.get("serverUrl") + if endpoint and "://" not in endpoint: + endpoint = f"https://{endpoint}" + elif plugin_type in {"databricks", "databricks_table"}: + endpoint = endpoint or fields.get("workspace_url") or fields.get("workspaceUrl") + plugin_type = "databricks" + elif plugin_type == "azure_maps_openlayers": + endpoint = endpoint or "https://atlas.microsoft.com" + if not endpoint: + return plugin_type, None + if not isinstance(endpoint, str): + raise OutboundDestinationError("The action destination URL is invalid.") + try: + scheme = urlsplit(endpoint).scheme.lower() + except ValueError as exc: + raise OutboundDestinationError("The action destination URL is invalid.") from exc + if scheme not in _SCHEME_PORTS: + if plugin_type in _HTTP_ACTION_TYPES: + raise OutboundDestinationError("This action requires an HTTP or remote MCP service URL.") + return plugin_type, None + return plugin_type, normalize_outbound_url(endpoint).origin diff --git a/application/single_app/functions_outbound_http.py b/application/single_app/functions_outbound_http.py new file mode 100644 index 000000000..535b624cb --- /dev/null +++ b/application/single_app/functions_outbound_http.py @@ -0,0 +1,325 @@ +# functions_outbound_http.py +"""Connection-bound HTTP transports for configurable actions, without app bootstrap.""" + +import asyncio +import socket +from contextlib import asynccontextmanager +from types import MethodType + +import aiohttp +import anyio +import httpcore +import httpx +import requests +from requests.adapters import HTTPAdapter +from requests.utils import get_environ_proxies, select_proxy +from urllib3.connection import HTTPConnection, HTTPSConnection +from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool +from urllib3.exceptions import NewConnectionError +from urllib3.util.connection import create_connection + +from functions_outbound_destinations import ( + OutboundDestinationError, + normalize_outbound_url, + resolve_outbound_addresses, + validate_outbound_address, + validate_outbound_headers, + validate_outbound_redirect, +) + + +def _reject_forward_proxy(url, proxies=None, *, trust_env=True): + selected = proxies if proxies is not None else get_environ_proxies(url) if trust_env else {} + if select_proxy(url, selected): + raise OutboundDestinationError( + "Forward-proxy routing is not supported for guarded action connections." + ) + + +class _GuardedConnection: + def _new_conn(self): + addresses = resolve_outbound_addresses(self.host, self.port) + last_error = None + for address in addresses: + try: + return create_connection( + (address, self.port), self.timeout, + source_address=self.source_address, socket_options=self.socket_options, + ) + except OSError as exc: + last_error = exc + raise NewConnectionError(self, "The action service could not be reached.") from last_error + + +class _GuardedHTTPConnection(_GuardedConnection, HTTPConnection): + pass + + +class _GuardedHTTPSConnection(_GuardedConnection, HTTPSConnection): + pass + + +class _GuardedHTTPPool(HTTPConnectionPool): + ConnectionCls = _GuardedHTTPConnection + + +class _GuardedHTTPSPool(HTTPSConnectionPool): + ConnectionCls = _GuardedHTTPSConnection + + +class GuardedHTTPAdapter(HTTPAdapter): + def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): + super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs) + self.poolmanager.pool_classes_by_scheme = { + "http": _GuardedHTTPPool, "https": _GuardedHTTPSPool, + } + + def proxy_manager_for(self, proxy, **proxy_kwargs): + raise OutboundDestinationError( + "Forward-proxy routing is not supported for guarded action connections." + ) + + def send(self, request, **kwargs): + target = validate_outbound_headers(request.url, request.headers) + if target.scheme not in {"http", "https"}: + raise OutboundDestinationError("This action requires an HTTP or HTTPS service URL.") + _reject_forward_proxy(request.url, kwargs.get("proxies") or {}, trust_env=False) + return super().send(request, **kwargs) + + +class _NoAmbientAuth(requests.auth.AuthBase): + def __call__(self, request): + return request + + +def guard_requests_session(session): + """Install protection before an SDK performs login or any other HTTP request.""" + if getattr(session, "_simplechat_outbound_guarded", False) is True: + return session + session.mount("http://", GuardedHTTPAdapter()) + session.mount("https://", GuardedHTTPAdapter()) + session.max_redirects = min(session.max_redirects, 5) + original_redirect = session.get_redirect_target + original_prepare = session.prepare_request + + def prepare_request(self, request): + if not request.auth and not self.auth: + request.auth = _NoAmbientAuth() + return original_prepare(request) + + def redirect_auth(self, request, response): + validate_outbound_redirect(response.request.url, request.url) + + def redirect_target(self, response): + location = original_redirect(response) + if location: + try: + return validate_outbound_redirect(response.request.url, location) + except OutboundDestinationError: + response.close() + raise + return None + + session.get_redirect_target = MethodType(redirect_target, session) + session.prepare_request = MethodType(prepare_request, session) + session.rebuild_auth = MethodType(redirect_auth, session) + session._simplechat_outbound_guarded = True + return session + + +def create_guarded_session(): + return guard_requests_session(requests.Session()) + + +def guarded_request(method, url, **kwargs): + target = normalize_outbound_url(url) + with create_guarded_session() as session: + return session.request(method, target.url, **kwargs) + + +def guarded_probe_request(method, url, *, max_bytes=65536, **kwargs): + """Keep connection probes bounded while returning normal response/JSON APIs.""" + target = normalize_outbound_url(url) + with create_guarded_session() as session: + response = session.request(method, target.url, stream=True, **kwargs) + try: + chunks = [] + size = 0 + if max_bytes: + for chunk in response.iter_content(chunk_size=8192): + size += len(chunk) + if size > max_bytes: + raise OutboundDestinationError("The action connection test response is too large.") + chunks.append(chunk) + response._content = b"".join(chunks) + response._content_consumed = True + return response + finally: + response.close() + + +def guard_login_credentials(credentials): + """Protect SDK credential callbacks that run before its client is returned.""" + if credentials is None: + return None + + class GuardedCredentials: + def login(self, session, *args, **kwargs): + guard_requests_session(session) + return credentials.login(session, *args, **kwargs) + + return GuardedCredentials() + + +class GuardedTCPConnector(aiohttp.TCPConnector): + async def _resolve_host(self, host, port, traces=None): + normalize_outbound_url(f"http://[{host}]:{port}" if ":" in host else f"http://{host}:{port}") + addresses = await super()._resolve_host(host, port, traces=traces) + for address in addresses: + validate_outbound_address(address["host"]) + if not addresses: + raise OutboundDestinationError("The action destination did not resolve to a usable address.") + return addresses + + async def _create_proxy_connection(self, req, traces, timeout): + raise OutboundDestinationError( + "Forward-proxy routing is not supported for guarded action connections." + ) + + +def create_guarded_aiohttp_session(**kwargs): + trace = aiohttp.TraceConfig() + + async def request_start(session, context, params): + validate_outbound_headers(str(params.url), params.headers) + + async def request_redirect(session, context, params): + try: + validate_outbound_redirect(str(params.url), params.response.headers.get("Location", "")) + except OutboundDestinationError: + params.response.close() + raise + + trace.on_request_start.append(request_start) + trace.on_request_redirect.append(request_redirect) + traces = [trace, *kwargs.pop("trace_configs", [])] + return aiohttp.ClientSession( + connector=GuardedTCPConnector(), trace_configs=traces, **kwargs, + ) + + +class _GuardedAsyncBackend(httpcore.AsyncNetworkBackend): + def __init__(self, backend): + self.backend = backend + + async def connect_tcp(self, host, port, timeout=None, local_address=None, socket_options=None): + try: + with anyio.fail_after(timeout): + addresses = await anyio.to_thread.run_sync( + resolve_outbound_addresses, host, port, abandon_on_cancel=True, + ) + last_error = None + for address in addresses: + try: + return await self.backend.connect_tcp( + address, port, timeout=timeout, local_address=local_address, + socket_options=socket_options, + ) + except (OSError, httpcore.ConnectError, httpcore.ConnectTimeout) as exc: + last_error = exc + except TimeoutError as exc: + raise httpcore.ConnectTimeout("The action connection timed out.") from exc + raise httpcore.ConnectError("The action service could not be reached.") from last_error + + async def connect_unix_socket(self, path, timeout=None, socket_options=None): + raise OutboundDestinationError("Unix sockets are not permitted for action HTTP connections.") + + async def sleep(self, seconds): + await self.backend.sleep(seconds) + + +class GuardedAsyncHTTPTransport(httpx.AsyncHTTPTransport): + def __init__(self, **kwargs): + if kwargs.get("proxy") is not None or kwargs.get("uds") is not None: + raise OutboundDestinationError("This guarded action transport requires a direct service connection.") + super().__init__(**kwargs) + self.trust_env = kwargs.get("trust_env", True) + # HTTPX 0.28 delegates connections to this HTTP Core network backend. + self._pool._network_backend = _GuardedAsyncBackend(self._pool._network_backend) + + async def handle_async_request(self, request): + validate_outbound_headers(str(request.url), request.headers) + _reject_forward_proxy(str(request.url), trust_env=self.trust_env) + return await super().handle_async_request(request) + + +def create_guarded_httpx_client(headers=None, timeout=None, auth=None): + async def response_received(response): + if response.is_redirect and response.headers.get("location"): + try: + validate_outbound_redirect(str(response.request.url), response.headers["location"]) + except OutboundDestinationError: + await response.aclose() + raise + + return httpx.AsyncClient( + headers=headers, auth=auth, timeout=timeout or httpx.Timeout(30, read=300), + transport=GuardedAsyncHTTPTransport(), event_hooks={"response": [response_received]}, + ) + + +@asynccontextmanager +async def guarded_websocket_connection(url, timeout=10): + # MCP's native WebSocket helper has no client-factory hook; supply a vetted socket. + from websockets.asyncio.client import connect + from websockets.exceptions import InvalidStatus + + class CheckedConnect(connect): + def process_redirect(self, exc): + return exc + + for redirect_index in range(6): + target = normalize_outbound_url(url) + if target.scheme not in {"ws", "wss"}: + raise OutboundDestinationError("This MCP transport requires a WebSocket URL.") + _reject_forward_proxy(target.url.replace("wss:", "https:", 1).replace("ws:", "http:", 1)) + sock = None + try: + with anyio.fail_after(timeout): + addresses = await anyio.to_thread.run_sync( + resolve_outbound_addresses, target.host, target.port, abandon_on_cancel=True, + ) + for address in addresses: + family = socket.AF_INET6 if ":" in address else socket.AF_INET + sock = socket.socket(family, socket.SOCK_STREAM) + sock.setblocking(False) + try: + await asyncio.get_running_loop().sock_connect(sock, (address, target.port)) + break + except OSError: + sock.close() + sock = None + if sock is None: + raise OSError("The MCP WebSocket service could not be reached.") + options = {"server_hostname": target.host} if target.scheme == "wss" else {} + try: + connection = await CheckedConnect( + target.url, sock=sock, proxy=None, subprotocols=["mcp"], + open_timeout=timeout, **options, + ) + except InvalidStatus as exc: + location = exc.response.headers.get("Location") + if exc.response.status_code not in {301, 302, 303, 307, 308} or not location: + raise + if redirect_index == 5: + raise OutboundDestinationError("Too many MCP WebSocket redirects.") from exc + url = validate_outbound_redirect(target.url, location) + continue + try: + yield connection + finally: + await connection.close() + return + finally: + if sock is not None: + sock.close() diff --git a/application/single_app/functions_workspace_authoring.py b/application/single_app/functions_workspace_authoring.py index 2594a770c..082abea2a 100644 --- a/application/single_app/functions_workspace_authoring.py +++ b/application/single_app/functions_workspace_authoring.py @@ -22,6 +22,7 @@ agent_credential_is_ready, agent_credential_scope, agent_credential_status, credential_value, ) +from functions_outbound_destinations import OutboundDestinationError, action_connection_signature EDITOR_SECRET_MASK = "***REDACTED***" @@ -485,6 +486,35 @@ def list_editor_records(kind, user_id, settings): return records +def validate_action_connection_update(existing, updated, submitted): + """Never carry an opaque saved credential to a different connector/origin.""" + try: + updated_signature = action_connection_signature(updated) + except OutboundDestinationError as exc: + raise WorkspaceAuthoringValidation(exc.public_message) from exc + if not existing: + return + try: + unchanged = action_connection_signature(existing) == updated_signature + except OutboundDestinationError: + # A previously unsafe record can be repaired, but not by retaining its secrets. + unchanged = False + if unchanged: + return + for path in editor_secret_paths(existing, "actions"): + retained = _get(updated, path) + if retained is _MISSING or retained in (None, ""): + continue + provided = _get(submitted, path) + if ( + provided is _MISSING or provided in (None, "") + or _is_placeholder(provided) or _is_reference(provided) + ): + raise WorkspaceAuthoringValidation( + "Re-enter or clear stored credentials when changing an action's connection destination." + ) + + def merge_editor_write(existing, payload, kind, *, creating=False, resolve_stored_placeholder=None): """Apply changed leaves; removals and clearing configured secrets require explicit intent.""" if not isinstance(payload, dict) or set(payload) - { @@ -587,6 +617,8 @@ def merge(before, changes, path=(), *, replace_objects=False): raise WorkspaceAuthoringValidation("Clear stored credentials explicitly before replacing their configuration.") if any(_is_placeholder(value) for _, value in _walk(result)): raise WorkspaceAuthoringValidation("A stored secret is unavailable. Re-enter its value.") + if kind == "actions": + validate_action_connection_update(existing, result, updates) return result diff --git a/application/single_app/functions_yamcs_operations.py b/application/single_app/functions_yamcs_operations.py index 1290c209f..db29e7aa4 100644 --- a/application/single_app/functions_yamcs_operations.py +++ b/application/single_app/functions_yamcs_operations.py @@ -222,3 +222,23 @@ def build_yamcs_basic_auth_header(username: Any, password: Any) -> str: credential = f"{str(username or '')}:{str(password or '')}" encoded = base64.b64encode(credential.encode("utf-8")).decode("ascii") return f"Basic {encoded}" + + +def build_yamcs_api_key_credentials(key): + # Schema discovery must not import the optional connection SDK. + try: + from yamcs.client import APIKeyCredentials + except ImportError: + try: + from yamcs.client import Credentials + except ImportError: + from yamcs.core.auth import Credentials + + class APIKeyCredentials(Credentials): + def __init__(self, value): + super().__init__(password=value) + + def before_request(self, session, auth_url): + session.headers.update({"x-api-key": self.password}) + + return APIKeyCredentials(key) diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 58c9ce048..0d5ea4dec 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -54,6 +54,12 @@ pillow==13.0.0 ffmpeg-binaries-compat==1.0.1 ffmpeg-python==0.2.0 semantic-kernel==1.39.4 +# Action destination guards use these clients' transport extension contracts. +mcp==1.30.0 +httpx==0.28.1 +httpcore==1.0.9 +urllib3==2.7.0 +websockets==15.0.1 # Required by Semantic Kernel's Handlebars template parser. PyMeta3==0.5.1 snowflake-connector-python[pandas]==4.7.3 diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 0309e6346..e06f3a2d4 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -6,6 +6,7 @@ import json import time import uuid +from copy import deepcopy import azure.cosmos as azure_cosmos from azure.cosmos.exceptions import CosmosHttpResponseError from azure.identity import DefaultAzureCredential @@ -30,6 +31,8 @@ from functions_settings import get_settings, is_tabular_processing_enabled, update_settings from functions_authentication import * from functions_appinsights import log_event +from functions_outbound_destinations import OutboundDestinationError, normalize_outbound_url +from functions_outbound_http import guard_login_credentials, guard_requests_session, guarded_probe_request from functions_action_manifest import ( McpActionOrigin, McpConfigurationError, @@ -154,6 +157,7 @@ YAMCS_DEFAULT_PROCESSOR, YAMCS_PLUGIN_TYPE, build_yamcs_basic_auth_header, + build_yamcs_api_key_credentials, normalize_yamcs_additional_fields, normalize_yamcs_server_url, yamcs_basic_auth_conflicts_with_auth_method, @@ -230,6 +234,7 @@ project_shared_record, read_group_editor_record, validate_editor_action_manifest, + validate_action_connection_update, ) @@ -958,6 +963,41 @@ def _validate_action_identity_for_scope(plugin_manifest, scope_type, scope_id): validate_action_identity_reference(plugin_manifest, scope_type, scope_id) +def _authorize_action_test_scope(data, existing_plugin, user_id, plugin_type): + scope_type, scope_id = _resolve_action_identity_context(data, existing_plugin, user_id) + if existing_plugin is not None: + stored_type = resolve_action_type(existing_plugin) + if stored_type == DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE: + stored_type = DATABRICKS_PLUGIN_TYPE + if stored_type != plugin_type: + raise PermissionError("The selected action does not match this connector.") + if scope_type == WORKSPACE_IDENTITY_SCOPE_PERSONAL: + ensure_editor_access('actions', user_id, get_settings()) + ensure_action_type_access('governance_user_actions', user_id, plugin_type, 'personal') + elif scope_type == WORKSPACE_IDENTITY_SCOPE_GROUP: + _resolve_group_for_test(user_id, scope_id) + ensure_action_type_access('governance_group_actions', user_id, plugin_type, 'group') + elif "Admin" not in session.get("user", {}).get("roles", []): + raise PermissionError("Admin role required for global action tests.") + return scope_type, scope_id + + +def _validate_flat_http_action_test(existing, plugin_type, endpoint, auth, fields, submitted): + provided_auth = dict(submitted.get('auth') or {}) + provided_fields = dict(submitted.get('additionalFields') or {}) + for flat_name, auth_name in (('auth_key', 'key'), ('username', 'identity')): + if flat_name in submitted: + provided_auth[auth_name] = submitted[flat_name] + for name in fields: + if name in submitted: + provided_fields[name] = submitted[name] + validate_action_connection_update( + existing, + {'type': plugin_type, 'endpoint': endpoint, 'auth': auth, 'additionalFields': fields}, + {'auth': provided_auth, 'additionalFields': provided_fields}, + ) + + def _enforce_mcp_destination_policy(plugin_manifest, scope_type, scope_id, operation, user_id=None, mcp_operation_id=""): """Enforce outbound MCP destination policy for save, discovery, and runtime-adjacent routes.""" if not isinstance(plugin_manifest, dict) or resolve_action_type(plugin_manifest) != MCP_PLUGIN_TYPE: @@ -1138,6 +1178,7 @@ def _prepare_editor_sql_test_data(data, existing_plugin, user_id): if not identity_manifest: auth_key = _resolve_secret_value_for_action_test( auth_key, 'auth.key', 'SQL', scope_value, scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=stored_auth.get('key'), ) if auth_type == 'connection_string': if auth_key is not None: @@ -1216,13 +1257,17 @@ def _prepare_editor_yamcs_test_data(data, existing_plugin, user_id): ACTION_ADDITIONAL_SECRET_SOURCES = {"action-addset"} -def _resolve_secret_value_for_action_test(value, field_name, plugin_label, scope_value, scope, allowed_sources): +def _resolve_secret_value_for_action_test( + value, field_name, plugin_label, scope_value, scope, allowed_sources, *, stored_value=None, +): """Resolve a Key Vault reference for an action test, bound to the action's own scope. Callers must pass an explicit scope and the allowed reference sources for the field being resolved. A caller-supplied reference name is untrusted input, so resolving it without a scope check would let an authenticated user read another user's, group's, or global action's secret and forward it to a caller-controlled endpoint. + References must also match the server-loaded value of this exact stored field; + a transient draft or a different action's reference is not credential re-entry. Sources mirror how the action was stored by ``keyvault_plugin_get_helper``: ``auth.*`` fields use ``action`` while ``additionalFields.*`` values and MCP custom headers use @@ -1235,6 +1280,10 @@ def _resolve_secret_value_for_action_test(value, field_name, plugin_label, scope if not validate_secret_name_dynamic(value): return value + if value != stored_value: + raise WorkspaceAuthoringValidation( + "Stored credentials must come from the selected action's original field. Re-enter the credential." + ) if not scope_value or not scope: raise ValueError( f"Unable to determine the Key Vault scope for {plugin_label} field '{field_name}'." @@ -1277,13 +1326,16 @@ def _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, sc scope_value, scope, ACTION_ADDITIONAL_SECRET_SOURCES, + stored_value=existing_headers.get(header_name), ) additional_fields[MCP_CUSTOM_HEADERS_FIELD] = hydrated_headers discovery_manifest['additionalFields'] = additional_fields -def _resolve_secret_value_for_sql_test(value, field_name, scope_value=None, scope="user"): +def _resolve_secret_value_for_sql_test( + value, field_name, scope_value=None, scope="user", *, stored_value=None, +): """Resolve a Key Vault reference for SQL test-connection flows. SQL secrets live in additionalFields (connection_string, password), so they carry the @@ -1297,6 +1349,7 @@ def _resolve_secret_value_for_sql_test(value, field_name, scope_value=None, scop scope_value, scope, ACTION_ADDITIONAL_SECRET_SOURCES, + stored_value=stored_value, ) @@ -1440,7 +1493,7 @@ def _prepare_personal_action_for_editor(user_id, plugin, settings, existing): return _prepare_personal_action_payload(user_id, plugin, editor=True, existing=existing) -def _prepare_personal_action_payload(user_id, plugin, *, editor=False, existing=None): +def _prepare_personal_action_payload(user_id, plugin, *, editor=False, existing=None, submitted=None): """Clean, default and validate a single personal action. This is the per-action half of the bulk save, factored out so the per-item create and @@ -1471,6 +1524,10 @@ def _prepare_personal_action_payload(user_id, plugin, *, editor=False, existing= plugin_to_save.setdefault('endpoint', '') _apply_plugin_runtime_defaults(plugin_to_save) plugin_type = plugin_to_save['type'] + try: + validate_action_connection_update(existing, plugin_to_save, plugin if submitted is None else submitted) + except WorkspaceAuthoringValidation as exc: + return None, (jsonify({'error': exc.public_message}), 400) try: _validate_action_identity_for_scope( plugin_to_save, @@ -1790,7 +1847,9 @@ def update_user_plugin(action_id): # body; that keeps a rename from orphaning the existing record. merged['id'] = existing.get('id') - plugin_to_save, error = _prepare_personal_action_payload(user_id, merged, existing=existing) + plugin_to_save, error = _prepare_personal_action_payload( + user_id, merged, existing=existing, submitted=updates, + ) if error: return error @@ -1906,7 +1965,12 @@ def set_user_plugins(): and legacy_action is None ): continue # Skip global plugins - existing_action = current_actions_by_id.get(submitted_id) if submitted_id else None + existing_action = (current_actions_by_id.get(submitted_id) if submitted_id else None) or legacy_action + if existing_action is None and not submitted_id: + matches = [action for action in current_actions if action.get('name') == plugin.get('name')] + if len(matches) > 1: + return jsonify({'error': 'Select an action ID before updating a duplicate name.'}), 400 + existing_action = matches[0] if matches else None plugin_to_save, error = _prepare_personal_action_payload(user_id, plugin, existing=existing_action) if error: return error @@ -2646,6 +2710,7 @@ def edit_plugin(plugin_name): break if found_plugin: + validate_action_connection_update(found_plugin, updated_plugin, updated_plugin) if is_legacy_msgraph_type(updated_plugin.get('type')) and requested_id != found_plugin.get('id'): return jsonify({'error': LEGACY_ACTION_CREATION_MESSAGE}), 400 duplicate_name = updated_plugin.get('name', '').lower() @@ -2845,9 +2910,11 @@ def discover_mcp_tools(): try: existing_plugin = _load_existing_plugin_for_test(payload.get('plugin_context'), user_id, requested_group_id=payload.get('group_id')) + scope_type, scope_id = _authorize_action_test_scope( + payload, existing_plugin, user_id, MCP_PLUGIN_TYPE, + ) if payload.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, payload['clear_secret_paths']) - scope_type, scope_id = _resolve_action_identity_context(payload, existing_plugin, user_id) origin = _action_test_origin(existing_plugin, scope_type, scope_id) plugin_scope_value, plugin_scope = _action_origin_secret_context(origin) @@ -2864,6 +2931,7 @@ def discover_mcp_tools(): discovery_manifest.setdefault('metadata', {}) discovery_manifest.setdefault('additionalFields', {}) _apply_plugin_runtime_defaults(discovery_manifest) + submitted_manifest = deepcopy(discovery_manifest) discovery_manifest = ScopedActionManifest(discovery_manifest, origin) _enforce_mcp_destination_policy( discovery_manifest, @@ -2901,10 +2969,12 @@ def discover_mcp_tools(): plugin_scope_value, plugin_scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=existing_auth.get('key'), ) discovery_manifest['auth'] = auth _hydrate_mcp_custom_headers_for_test(discovery_manifest, existing_plugin, plugin_scope_value, plugin_scope) + validate_action_connection_update(existing_plugin, discovery_manifest, submitted_manifest) is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(discovery_manifest, MCP_PLUGIN_TYPE) if not is_valid: @@ -3187,6 +3257,10 @@ def reject_configuration(message, status=400): try: context.stage = "identity_resolution" existing_plugin = _load_existing_plugin_for_sql_test(data.get('existing_plugin'), user_id, requested_group_id=data.get('group_id')) + sql_type = data.get('type') or (existing_plugin or {}).get('type') or 'sql_query' + if sql_type not in {'sql_query', 'sql_schema'}: + raise PermissionError("The selected SQL action type is invalid.") + _authorize_action_test_scope(data, existing_plugin, user_id, sql_type) if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) if editor_call: @@ -3278,12 +3352,14 @@ def reject_configuration(message, status=400): 'connection_string', scope_value=plugin_scope_value, scope=plugin_scope, + stored_value=existing_additional_fields.get('connection_string'), ) password = _resolve_secret_value_for_sql_test( password, 'password', scope_value=plugin_scope_value, scope=plugin_scope, + stored_value=existing_additional_fields.get('password'), ) except ValueError: return reject_configuration('A stored SQL credential could not be resolved.') @@ -3444,6 +3520,8 @@ def reject_configuration(message, status=400): def test_cosmos_connection(): """Test an Azure Cosmos DB for NoSQL connection using managed identity or an account key.""" data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({'success': False, 'error': 'Invalid Cosmos connection test payload.'}), 400 user_id = get_current_user_id() endpoint = (data.get('endpoint') or '').strip() database_name = (data.get('database_name') or '').strip() @@ -3470,6 +3548,7 @@ def test_cosmos_connection(): try: existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id, requested_group_id=data.get('group_id')) + _authorize_action_test_scope(data, existing_plugin, user_id, 'cosmos_query') if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) except PermissionError as exc: @@ -3499,6 +3578,7 @@ def test_cosmos_connection(): plugin_scope_value, plugin_scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=existing_auth.get('key'), ) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 @@ -3607,6 +3687,7 @@ def test_yamcs_connection(): data = request.get_json(silent=True) or {} if not isinstance(data, dict): return jsonify({'success': False, 'error': 'Yamcs test configuration must be an object.'}), 400 + submitted_data = deepcopy(data) user_id = get_current_user_id() editor_call = _is_personal_editor_test_request(data) and ( 'auth' in data or 'additionalFields' in data or bool(data.get('identity_id')) @@ -3615,6 +3696,7 @@ def test_yamcs_connection(): if editor_call: try: existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id, requested_group_id=data.get('group_id')) + _authorize_action_test_scope(data, existing_plugin, user_id, YAMCS_PLUGIN_TYPE) if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) data = _prepare_editor_yamcs_test_data(data, existing_plugin, user_id) @@ -3660,6 +3742,7 @@ def test_yamcs_connection(): if not editor_call: try: existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id, requested_group_id=data.get('group_id')) + _authorize_action_test_scope(data, existing_plugin, user_id, YAMCS_PLUGIN_TYPE) if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) except PermissionError as exc: @@ -3691,6 +3774,7 @@ def test_yamcs_connection(): plugin_scope_value, plugin_scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=existing_auth.get('key'), ) except ValueError as exc: if editor_call: @@ -3731,6 +3815,7 @@ def test_yamcs_connection(): plugin_scope_value, plugin_scope, ACTION_ADDITIONAL_SECRET_SOURCES, + stored_value=existing_additional_fields.get('basic_auth_password'), ) except ValueError as exc: logging.warning("Failed to resolve Yamcs basic auth password for action test: %s", exc) @@ -3747,7 +3832,7 @@ def test_yamcs_connection(): client = None try: try: - from yamcs.client import APIKeyCredentials, Credentials, YamcsClient + from yamcs.client import Credentials, YamcsClient except ImportError: return jsonify({ 'success': False, @@ -3766,20 +3851,28 @@ def test_yamcs_connection(): }), 400 credentials = BasicAuthCredentials(basic_auth_username, basic_auth_password) elif auth_method == YAMCS_AUTH_METHOD_API_KEY: - credentials = APIKeyCredentials(auth_key) + credentials = build_yamcs_api_key_credentials(auth_key) elif auth_method == YAMCS_AUTH_METHOD_BEARER_TOKEN: credentials = Credentials(access_token=auth_key) else: credentials = Credentials(username=username, password=auth_key) + normalize_outbound_url(server_url) + _validate_flat_http_action_test( + existing_plugin, YAMCS_PLUGIN_TYPE, server_url, + {'key': auth_key, 'identity': username} if auth_method != YAMCS_AUTH_METHOD_NONE else {}, + {'server_url': server_url, 'basic_auth_password': basic_auth_password if enable_basic_auth else ''}, + submitted_data, + ) client = YamcsClient( server_url, - credentials=credentials, + credentials=guard_login_credentials(credentials), tls_verify=tls_verify, user_agent='SimpleChat', ) session = getattr(getattr(client, 'ctx', None), 'session', None) if session is not None: + guard_requests_session(session) original_request = session.request def request_with_timeout(*args, **kwargs): @@ -3831,6 +3924,8 @@ def request_with_timeout(*args, **kwargs): f"Instance '{instance}' is available ({len(instance_names)} instance(s) total)." ).strip() }) + except (OutboundDestinationError, WorkspaceAuthoringValidation) as exc: + return jsonify({'success': False, 'error': exc.public_message}), 400 except Exception as exc: status_code = getattr(getattr(exc, 'response', None), 'status_code', None) raw_message = str(exc) @@ -3879,6 +3974,8 @@ def request_with_timeout(*args, **kwargs): def test_rocksdb_connection(): """Probe a RocksDB HTTP service and confirm it answers with the expected contract.""" data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({'success': False, 'error': 'Invalid RocksDB connection test payload.'}), 400 user_id = get_current_user_id() auth_scheme = (data.get('auth_scheme') or AUTH_SCHEME_NONE).strip().lower() api_key_header = (data.get('api_key_header') or '').strip() or DEFAULT_API_KEY_HEADER @@ -3900,6 +3997,7 @@ def test_rocksdb_connection(): try: existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id, requested_group_id=data.get('group_id')) + _authorize_action_test_scope(data, existing_plugin, user_id, 'rocksdb') if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) except PermissionError: @@ -3931,6 +4029,7 @@ def test_rocksdb_connection(): plugin_scope_value, plugin_scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=existing_auth.get('key'), ) except ValueError: return jsonify({ @@ -3951,7 +4050,13 @@ def test_rocksdb_connection(): headers[api_key_header] = auth_key try: - response = requests.get( + _validate_flat_http_action_test( + existing_plugin, 'rocksdb', base_url, + {'key': auth_key} if auth_scheme != AUTH_SCHEME_NONE else {}, + {'base_url': base_url}, data, + ) + response = guarded_probe_request( + "GET", f'{base_url}/health', headers=headers, timeout=timeout, @@ -3959,7 +4064,8 @@ def test_rocksdb_connection(): if response.status_code == 404: # Minimal services may not implement /health, so fall back to a single-item scan. - response = requests.post( + response = guarded_probe_request( + "POST", f'{base_url}/scan', json={'limit': 1}, headers=headers, @@ -3995,6 +4101,8 @@ def test_rocksdb_connection(): 'success': True, 'message': f'Successfully reached the RocksDB service at {base_url}.' }) + except (OutboundDestinationError, WorkspaceAuthoringValidation) as exc: + return jsonify({'success': False, 'error': exc.public_message}), 400 except requests.exceptions.SSLError: return jsonify({ 'success': False, @@ -4041,9 +4149,9 @@ def _prepare_action_test_manifest(data, plugin_type, plugin_label): user_id = get_current_user_id() existing_plugin = _load_existing_plugin_for_test(data.get('plugin_context'), user_id, requested_group_id=data.get('group_id')) + scope_type, scope_id = _authorize_action_test_scope(data, existing_plugin, user_id, plugin_type) if data.get('clear_secret_paths'): existing_plugin = clear_editor_test_secrets(existing_plugin, data['clear_secret_paths']) - scope_type, scope_id = _resolve_action_identity_context(data, existing_plugin, user_id) # Secret references are resolved against the loaded action's own Key Vault scope, never # against a scope derived from the request body. origin = _action_test_origin(existing_plugin, scope_type, scope_id) @@ -4066,6 +4174,7 @@ def _prepare_action_test_manifest(data, plugin_type, plugin_label): manifest['identity_id'] = identity_id _apply_plugin_runtime_defaults(manifest) + submitted_manifest = deepcopy(manifest) manifest = ScopedActionManifest(manifest, origin) if plugin_type == MCP_PLUGIN_TYPE: _enforce_mcp_destination_policy( @@ -4144,6 +4253,7 @@ def _prepare_action_test_manifest(data, plugin_type, plugin_label): plugin_scope_value, plugin_scope, ACTION_AUTH_SECRET_SOURCES, + stored_value=existing_auth.get('key'), ) manifest['auth'] = auth additional_fields = manifest.get('additionalFields') if isinstance(manifest.get('additionalFields'), dict) else {} @@ -4155,9 +4265,11 @@ def _prepare_action_test_manifest(data, plugin_type, plugin_label): plugin_scope_value, plugin_scope, ACTION_ADDITIONAL_SECRET_SOURCES, + stored_value=existing_additional_fields.get('private_key_passphrase'), ) manifest['additionalFields'] = additional_fields + validate_action_connection_update(existing_plugin, manifest, submitted_manifest) is_valid, validation_errors = PluginHealthChecker.validate_plugin_manifest(manifest, plugin_type) if not is_valid: raise ValueError('; '.join(validation_errors) or f'The {plugin_label} action configuration is invalid.') diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 40ebcd1f6..b8b925d66 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -20,7 +20,6 @@ from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.filters import FilterTypes -from semantic_kernel.core_plugins import HttpPlugin from semantic_kernel_plugins.time_plugin import TimePlugin from semantic_kernel_plugins.wait_plugin import WaitPlugin from semantic_kernel_plugins.math_plugin import MathPlugin @@ -1210,23 +1209,13 @@ def load_time_plugin(kernel: Kernel): ) def load_http_plugin(kernel: Kernel): - try: - # Use smart HTTP plugin with 75k character limit (≈50k tokens) - smart_plugin = SmartHttpPlugin(max_content_size=75000, extract_text_only=True) - kernel.add_plugin( - smart_plugin, - plugin_name="http", - description="Provides HTTP request functions with intelligent content size management for web scraping." - ) - log_event("[SK_LOADER] Loaded Smart HTTP plugin with content size limits.", level=logging.INFO) - except ImportError as e: - log_event(f"[SK_LOADER] Smart HTTP plugin not available, falling back to standard HttpPlugin: {e}", level=logging.WARNING) - # Fallback to standard HTTP plugin - kernel.add_plugin( - HttpPlugin(), - plugin_name="http", - description="Provides HTTP request functions for making API calls." - ) + smart_plugin = SmartHttpPlugin(max_content_size=75000, extract_text_only=True) + kernel.add_plugin( + smart_plugin, + plugin_name="http", + description="Provides HTTP request functions with intelligent content size management for web scraping." + ) + log_event("[SK_LOADER] Loaded Smart HTTP plugin with content size limits.", level=logging.INFO) def load_wait_plugin(kernel: Kernel): kernel.add_plugin( diff --git a/application/single_app/semantic_kernel_plugins/databricks_plugin.py b/application/single_app/semantic_kernel_plugins/databricks_plugin.py index ec54ce08d..51591b7f9 100644 --- a/application/single_app/semantic_kernel_plugins/databricks_plugin.py +++ b/application/single_app/semantic_kernel_plugins/databricks_plugin.py @@ -15,6 +15,7 @@ from functions_appinsights import log_event from functions_debug import debug_print +from functions_outbound_http import guarded_request from functions_azure_endpoint_validation import validate_azure_databricks_endpoint from functions_databricks_operations import ( DATABRICKS_ALLOWED_READ_STATEMENTS, @@ -269,7 +270,8 @@ def _submit_statement(self, statement: str) -> Dict[str, Any]: f"warehouse_id_present={bool(self.warehouse_id)} first_word={self._first_word(statement) if hasattr(self, '_first_word') else ''} " f"statement_length={len(statement)} timeout={self.timeout} wait_timeout={self.wait_timeout}" ) - response = requests.post( + response = guarded_request( + "POST", self._statement_url(), headers=self._headers(), json=payload, @@ -297,7 +299,8 @@ def _wait_for_statement(self, statement_response: Dict[str, Any]) -> Dict[str, A f"[DATABRICKS_PLUGIN] Polling statement status statement_id={statement_id} " f"current_state={state} timeout={self.timeout}" ) - response = requests.get( + response = guarded_request( + "GET", self._statement_url(statement_id), headers=self._headers(), timeout=self.timeout, diff --git a/application/single_app/semantic_kernel_plugins/mcp_guarded_transport.py b/application/single_app/semantic_kernel_plugins/mcp_guarded_transport.py new file mode 100644 index 000000000..e619709cc --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/mcp_guarded_transport.py @@ -0,0 +1,53 @@ +# mcp_guarded_transport.py +"""MCP's WebSocket wire contract over a destination-guarded connection.""" + +from contextlib import asynccontextmanager + +import anyio +from mcp.shared.message import SessionMessage +from mcp.types import JSONRPCMessage +from pydantic import ValidationError +from semantic_kernel.connectors.mcp import MCPWebsocketPlugin + +from functions_outbound_http import guarded_websocket_connection + + +@asynccontextmanager +async def guarded_mcp_websocket_client(url, timeout): + incoming_sender, incoming = anyio.create_memory_object_stream(0) + outgoing, outgoing_receiver = anyio.create_memory_object_stream(0) + + async with guarded_websocket_connection(url, timeout=timeout) as connection: + async def receive_messages(): + async with incoming_sender: + async for raw_message in connection: + try: + message = SessionMessage(JSONRPCMessage.model_validate_json(raw_message)) + except ValidationError as exc: + await incoming_sender.send(exc) + else: + await incoming_sender.send(message) + + async def send_messages(): + async with outgoing_receiver: + async for message in outgoing_receiver: + await connection.send( + message.message.model_dump_json(by_alias=True, exclude_none=True) + ) + + async with anyio.create_task_group() as tasks: + tasks.start_soon(receive_messages) + tasks.start_soon(send_messages) + try: + yield incoming, outgoing + finally: + tasks.cancel_scope.cancel() + + +class GuardedMcpWebsocketPlugin(MCPWebsocketPlugin): + def __init__(self, *args, connect_timeout=10, **kwargs): + super().__init__(*args, **kwargs) + self.connect_timeout = connect_timeout + + def get_mcp_client(self): + return guarded_mcp_websocket_client(self.url, self.connect_timeout) diff --git a/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py b/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py index d17fd7a4e..0e00184c2 100644 --- a/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py +++ b/application/single_app/semantic_kernel_plugins/mcp_plugin_factory.py @@ -11,7 +11,6 @@ from semantic_kernel.connectors.mcp import ( MCPSsePlugin, MCPStreamableHttpPlugin, - MCPWebsocketPlugin, _mcp_call_tool_result_to_kernel_contents, ) @@ -25,6 +24,7 @@ resolve_action_type, ) from functions_debug import debug_print +from functions_outbound_http import create_guarded_httpx_client from functions_mcp_operations import ( MCP_CUSTOM_HEADERS_FIELD, MCP_PLUGIN_TYPE, @@ -54,6 +54,7 @@ ) from functions_mcp_preconfigurations import authorize_mcp_action from semantic_kernel_plugins.mcp_plugin import McpPlugin +from semantic_kernel_plugins.mcp_guarded_transport import GuardedMcpWebsocketPlugin class McpPluginFactory: @@ -562,15 +563,17 @@ def create_connector(cls, config: Dict[str, Any], *, origin: Optional[McpActionO load_prompts=load_prompts, request_timeout=request_timeout, description=description, + httpx_client_factory=create_guarded_httpx_client, ) if transport == "websocket": - return MCPWebsocketPlugin( + return GuardedMcpWebsocketPlugin( name=name, url=endpoint, load_tools=load_tools, load_prompts=load_prompts, request_timeout=request_timeout, description=description, + connect_timeout=timeout, ) return MCPStreamableHttpPlugin( @@ -584,6 +587,7 @@ def create_connector(cls, config: Dict[str, Any], *, origin: Optional[McpActionO load_prompts=load_prompts, request_timeout=request_timeout, description=description, + httpx_client_factory=create_guarded_httpx_client, ) @classmethod diff --git a/application/single_app/semantic_kernel_plugins/openapi_plugin.py b/application/single_app/semantic_kernel_plugins/openapi_plugin.py index af7eec16a..dc590722e 100644 --- a/application/single_app/semantic_kernel_plugins/openapi_plugin.py +++ b/application/single_app/semantic_kernel_plugins/openapi_plugin.py @@ -62,6 +62,8 @@ report_plugin_invocation_failure, ) from functions_debug import debug_print +from functions_outbound_destinations import OutboundDestinationError +from functions_outbound_http import guarded_request OPENAPI_REDACTED_VALUE = "***REDACTED***" @@ -1106,26 +1108,26 @@ def _call_api_operation(self, operation_id: str, path: str, method: str, operati logging.info(f"[OPEN_API_PLUGIN] Query params: {_redact_openapi_value(query_params)}") if method.lower() == 'get': - response = requests.get(full_url, headers=headers, params=query_params, timeout=30) + response = guarded_request("GET", full_url, headers=headers, params=query_params, timeout=30) # Log the actual URL that was requested debug_print(f"Actual GET request URL: {_redact_openapi_url(response.url)}") debug_print(f"Response status: {response.status_code}") logging.info(f"[OPEN_API_PLUGIN] Actual GET request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'post': - response = requests.post(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = guarded_request("POST", full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual POST request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'put': - response = requests.put(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = guarded_request("PUT", full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual PUT request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'delete': - response = requests.delete(full_url, headers=headers, params=query_params, timeout=30) + response = guarded_request("DELETE", full_url, headers=headers, params=query_params, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual DELETE request URL: {_redact_openapi_url(response.url)}") elif method.lower() == 'patch': - response = requests.patch(full_url, headers=headers, params=query_params, json=kwargs, timeout=30) + response = guarded_request("PATCH", full_url, headers=headers, params=query_params, json=kwargs, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual PATCH request URL: {_redact_openapi_url(response.url)}") else: # Default to GET for unknown methods - response = requests.get(full_url, headers=headers, params=query_params, timeout=30) + response = guarded_request("GET", full_url, headers=headers, params=query_params, timeout=30) logging.info(f"[OPEN_API_PLUGIN] Actual GET request URL: {_redact_openapi_url(response.url)}") debug_print(f"Response status: {response.status_code}") @@ -1224,6 +1226,9 @@ def _call_api_operation(self, operation_id: str, path: str, method: str, operati report_plugin_invocation_failure(f"HTTP {response.status_code}") return error_result + except OutboundDestinationError as exc: + report_plugin_invocation_failure("OpenAPI destination policy rejected the request.") + return {"error": exc.public_message, "error_type": "destination_blocked", "operation_id": operation_id} except requests.exceptions.RequestException as req_error: redacted_request_error = _redact_openapi_string(str(req_error)) debug_print(f"Request exception: {redacted_request_error}") diff --git a/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py b/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py index 2db297b40..4eff762b9 100644 --- a/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py +++ b/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py @@ -20,6 +20,8 @@ from semantic_kernel.functions import kernel_function from functions_appinsights import log_event, sanitize_log_message +from functions_outbound_destinations import normalize_outbound_url +from functions_outbound_http import create_guarded_session from semantic_kernel_plugins.base_plugin import BasePlugin from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger @@ -62,7 +64,7 @@ def normalize_rocksdb_base_url(base_url: str) -> str: raise ValueError("RocksDB service URLs must use the http or https scheme.") if not parsed_url.netloc: raise ValueError("RocksDB service URLs must include a host name.") - return cleaned_url + return normalize_outbound_url(cleaned_url).url.rstrip("/") class RocksDbResult: @@ -734,7 +736,7 @@ def _remote_request( ) -> Dict[str, Any]: session = self._http_session if session is None: - session = requests.Session() + session = create_guarded_session() self._http_session = session request_payload = None @@ -746,7 +748,7 @@ def _remote_request( response = session.request( method, - f"{self.base_url}{path}", + normalize_outbound_url(f"{self.base_url}{path}").url, json=request_payload, headers=self._build_remote_headers(), timeout=self.timeout, diff --git a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py index 6c148bc57..0506f00b2 100644 --- a/application/single_app/semantic_kernel_plugins/smart_http_plugin.py +++ b/application/single_app/semantic_kernel_plugins/smart_http_plugin.py @@ -44,6 +44,9 @@ import re import functools +from functions_outbound_destinations import OutboundDestinationError, normalize_outbound_url +from functions_outbound_http import create_guarded_aiohttp_session + def async_plugin_logger(plugin_name: str): """Async-compatible plugin function logger decorator.""" def decorator(func): @@ -216,8 +219,9 @@ async def get_web_content_async(self, uri: str) -> str: content_type = "unknown" try: + uri = normalize_outbound_url(uri).url timeout = aiohttp.ClientTimeout(total=30) - async with aiohttp.ClientSession(timeout=timeout) as session: + async with create_guarded_aiohttp_session(timeout=timeout) as session: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } @@ -276,6 +280,10 @@ async def get_web_content_async(self, uri: str) -> str: self._track_function_call("get_web_content", parameters, result, call_start, uri, "text/plain") return result + except OutboundDestinationError as exc: + error_result = f"Error: {exc.public_message}" + self._track_function_call("get_web_content", parameters, error_result, call_start, uri, "destination_policy") + return error_result except asyncio.TimeoutError: error_result = "Error: Request timed out (30 seconds). The website may be slow or unresponsive." self._track_function_call("get_web_content", parameters, error_result, call_start, uri, "timeout") @@ -727,8 +735,9 @@ async def post_web_content_async(self, uri: str, body: str) -> str: parameters = {"uri": uri, "body": body[:100] + "..." if len(body) > 100 else body} # Truncate body for display try: + uri = normalize_outbound_url(uri).url timeout = aiohttp.ClientTimeout(total=30) - async with aiohttp.ClientSession(timeout=timeout) as session: + async with create_guarded_aiohttp_session(timeout=timeout) as session: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', 'Content-Type': 'application/json' @@ -750,6 +759,10 @@ async def post_web_content_async(self, uri: str, body: str) -> str: self._track_function_call("post_web_content", parameters, result, call_start, uri, "application/json") return result + except OutboundDestinationError as exc: + error_result = f"Error: {exc.public_message}" + self._track_function_call("post_web_content", parameters, error_result, call_start, uri, "destination_policy") + return error_result except Exception as e: self.logger.error(f"Error posting to {uri}: {str(e)}") error_result = f"Error posting content: {str(e)}" diff --git a/application/single_app/semantic_kernel_plugins/tableau_plugin.py b/application/single_app/semantic_kernel_plugins/tableau_plugin.py index ee19631d3..47bb8ee12 100644 --- a/application/single_app/semantic_kernel_plugins/tableau_plugin.py +++ b/application/single_app/semantic_kernel_plugins/tableau_plugin.py @@ -9,6 +9,8 @@ from functions_appinsights import log_event from functions_debug import debug_print +from functions_outbound_destinations import normalize_outbound_url +from functions_outbound_http import create_guarded_session from functions_tableau_operations import ( TABLEAU_AUTH_METHOD_PAT, TABLEAU_AUTH_METHOD_USERNAME_PASSWORD, @@ -182,6 +184,7 @@ def _get_tableau_auth(self): ) def _create_server(self): + normalize_outbound_url(self.endpoint) tsc = self._require_tsc() debug_print( f"[TABLEAU_PLUGIN] Creating Tableau server client endpoint={self.endpoint} " @@ -189,7 +192,10 @@ def _create_server(self): f"auth_method={self.auth_method} timeout={self.timeout} " f"use_server_version={self.use_server_version}" ) - server = tsc.Server(self.endpoint, use_server_version=self.use_server_version) + server = tsc.Server( + self.endpoint, use_server_version=self.use_server_version, + session_factory=create_guarded_session, http_options={"timeout": self.timeout}, + ) if hasattr(server, "add_http_options"): try: server.add_http_options({"timeout": self.timeout}) diff --git a/application/single_app/semantic_kernel_plugins/yamcs_plugin.py b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py index ab65900ff..c546780f2 100644 --- a/application/single_app/semantic_kernel_plugins/yamcs_plugin.py +++ b/application/single_app/semantic_kernel_plugins/yamcs_plugin.py @@ -16,6 +16,8 @@ from functions_appinsights import log_event from functions_debug import debug_print +from functions_outbound_destinations import normalize_outbound_url +from functions_outbound_http import guard_login_credentials, guard_requests_session from functions_yamcs_operations import ( YAMCS_ALLOWED_READ_STATEMENTS, YAMCS_AUTH_METHOD_API_KEY, @@ -28,6 +30,7 @@ YAMCS_SUPPORTED_AUTH_METHODS, YAMCS_SUPPORTED_AUTH_TYPES, build_yamcs_basic_auth_header, + build_yamcs_api_key_credentials, normalize_yamcs_additional_fields, normalize_yamcs_server_url, yamcs_basic_auth_conflicts_with_auth_method, @@ -295,20 +298,7 @@ def _build_credentials(self): auth_key = str(self._auth.get("key") or "") if self.auth_method == YAMCS_AUTH_METHOD_API_KEY: - try: - from yamcs.client import APIKeyCredentials - except ImportError: - # yamcs-client 1.8.8 exposes core Credentials, but no API-key - # holder. Its Context calls before_request for every request. - class APIKeyCredentials(Credentials): - def __init__(self, api_key): - super().__init__() - self.api_key = api_key - - def before_request(self, session, auth_url): - session.headers.update({"x-api-key": self.api_key}) - - return APIKeyCredentials(auth_key) + return build_yamcs_api_key_credentials(auth_key) if self.auth_method == YAMCS_AUTH_METHOD_BEARER_TOKEN: return Credentials(access_token=auth_key) return Credentials(username=str(self._auth.get("identity") or ""), password=auth_key) @@ -333,6 +323,7 @@ def _build_basic_auth_credentials(self): return BasicAuthCredentials(self.basic_auth_username, self.basic_auth_password) def _connect(self): + normalize_outbound_url(self.server_url) try: from yamcs.client import YamcsClient except ImportError as exc: @@ -363,7 +354,7 @@ def login(self, session, auth_root, on_token_update): credentials = BoundCredentials() client = YamcsClient( self.server_url, - credentials=credentials, + credentials=guard_login_credentials(credentials), tls_verify=self.tls_verify, user_agent="SimpleChat", ) @@ -371,6 +362,7 @@ def login(self, session, auth_root, on_token_update): # slow or unreachable ground segment cannot hang an agent turn indefinitely. session = getattr(getattr(client, "ctx", None), "session", None) if session is not None: + guard_requests_session(session) session.request = self._with_timeout(session.request) self._apply_basic_auth_header(session) return client diff --git a/docs/admin/governance.md b/docs/admin/governance.md index 82898a0f3..b677a1ed0 100644 --- a/docs/admin/governance.md +++ b/docs/admin/governance.md @@ -194,6 +194,17 @@ Use destination governance to limit which remote MCP servers personal, group, an actions may contact. Policies apply to saves, discovery, connection tests, and tool execution, not just to the server choices shown in the action editor. +Starting in **0.261.435** (`application/single_app/config.py`), remote MCP +connections also have a mandatory safety floor independent of the switches +below. Loopback, link-local and cloud infrastructure destinations cannot be +enabled by an allowlist, including when a hostname resolves to such an address. +The transport checks the addresses it actually connects to. + +This is not a blanket private-network ban: legitimate enterprise private +FQDNs/IPs remain supported unless an existing stricter policy excludes them. +See [action connection compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +for Yamcs reverse proxies and upgrade considerations. + #### Settings | Setting | What it does | Default | Notes | @@ -211,12 +222,13 @@ it is enforced even while the switch above is off. A destination is decided in this order: -1. With blocking on, an unsafe address is refused. -2. With enforcement off, everything else is allowed. -3. A policy that blocks the person refuses any destination its pattern matches, even if +1. The mandatory connection safety floor always refuses forbidden destinations. +2. With optional private/local IP blocking on, its additional literal-address restrictions apply. +3. With enforcement off, other destinations remain eligible under the safety floor. +4. A policy that blocks the person refuses any destination its pattern matches, even if another policy allows it. -4. A policy that allows the person allows any destination its pattern matches. -5. Anything else is refused. +5. A policy that allows the person allows any destination its pattern matches. +6. Anything else is refused. #### Destination policies diff --git a/docs/explanation/fixes/PLUGIN_OUTBOUND_DESTINATION_GUARD_FIX.md b/docs/explanation/fixes/PLUGIN_OUTBOUND_DESTINATION_GUARD_FIX.md new file mode 100644 index 000000000..fd6437505 --- /dev/null +++ b/docs/explanation/fixes/PLUGIN_OUTBOUND_DESTINATION_GUARD_FIX.md @@ -0,0 +1,113 @@ +# Plugin outbound destination guard (v0.261.436) + +Fixed/Implemented in version: **0.261.435**. + +The application version is recorded in `application/single_app/config.py`. +There is no new capability toggle, administrator destination allowlist, or +deployer version change. + +## Issue and root cause + +Configurable action connection tests and runtime clients did not consistently +enforce a destination boundary. URL shape checks, optional MCP literal-address +checks and user authentication did not establish which address a client would +actually connect to. Redirects and a second DNS lookup could undermine an +earlier check. + +Some connection tests also did not apply the corresponding authoring/type +permissions, and changing an endpoint could implicitly reuse an opaque saved +credential. Hiding a secret in the editor does not prevent the server from +forwarding it to a different destination. + +## Technical changes + +`functions_outbound_destinations.py` supplies bootstrap-free URL/origin and IP +rules. Legitimate private routing remains allowed. Loopback, link-local, +unspecified, multicast, reserved non-host and cloud infrastructure destinations +are rejected, including mapped address representations. + +`functions_outbound_http.py` binds direct Requests, aiohttp and HTTPX connections +to vetted addresses without substituting an IP for the original HTTP Host or +TLS hostname. All DNS candidates must be acceptable. Redirects are bounded and +cannot cross origins or downgrade TLS; a default-port upgrade on the same host +is allowed. Requests clients do not import ambient server `.netrc` credentials. +Connection probes consume only headers or a bounded response body as needed. + +MCP SSE/streamable HTTP use the native client's guarded HTTPX factory hook. +The compatible WebSocket adapter preserves native MCP message streams while +using a vetted socket, rechecking redirected connections and closing resources. +Existing MCP scope, allowlist, tool-pinning and authentication rules remain. +Destination-policy failures are non-retryable and use safe error categories. + +The client integrations cover OpenAPI, RocksDB, Yamcs, Tableau, Smart HTTP, +Databricks and the shared HTTP connection probes. SDK transport protection is +installed before initial authentication or version discovery. The loader does +not fall back to an unguarded HTTP plugin. + +`route_backend_plugins.py` authorizes personal/group/global connection tests +before credential use. `functions_workspace_authoring.py` checks authoritative +connection origins during merges; the legacy personal/global write paths and +transient test/discovery paths apply the same credential-retention rule. +Changing an origin or connector requires explicit replacement or clearing of +retained credentials. Unchanged-origin edits and credential rotation are not +blanket-reset. +Client-supplied Key Vault names must match the selected stored action field; +scope membership alone is not permission to dereference a name in a new draft. + +The existing Cosmos response normalization and conditional-write contracts +remain intact. Module fingerprint reviews are renewed with owner-runtime SDK +evidence. The touched Yamcs bootstrap doubles use the shared SDK-default +container, including native point responses, ETags, query rows and stale-write +rejection. + +## Compatibility and upgrade impact + +Public services, reachable private IPs/FQDNs and enterprise private DNS remain +supported. Unchanged actions do not require a mandatory re-save or credential +reset. Shared-action execution does not acquire a new editor-role requirement. +Application-owned Azure identity authentication and App Service inbound routing +are outside this action transport guard. + +Yamcs reverse-proxy authentication remains supported with No Authentication or +API Key authentication, including reusable/personal proxy identities. A remote +proxy forwarding to localhost on its own server is not a localhost target for +SimpleChat. A localhost URL configured on an App Service action instead points +at the SimpleChat worker/container and is refused. + +Cross-origin redirects require configuring the final service URL. Deployment- +wide HTTP(S) forward-proxy routing is explicitly refused by the guarded clients, +not silently disabled or used as a destination-check bypass. Deployments using +that routing need separate compatibility review. Existing supported TLS/CA and +timeout options remain; certificate verification is not disabled to pin an IP. + +This is not a redesign of non-HTTP database protocols or a complete audit of +every application feature. Custom server-side plugin implementations must use +the guarded connection helpers for their own configurable HTTP calls. + +## Validation + +The regressions use synthetic credentials, scoped storage seams and blocked +network access; no production, on-premises or metadata service is probed. + +The v0.261.436 CodeQL follow-up compares parsed URL origins and initializes +inherited SDK credential fixtures through their base constructors. It removes +unused test code while retaining the required WebSocket DNS-rejection exception +and connection-count checks. Production connector behavior is unchanged. + +- `functional_tests/test_outbound_destination_policy.py` exercises forbidden + literal/DNS targets, mixed answers, rebinding, mapped addresses, redirects, + actual connection selection, TLS/Host preservation and cleanup. +- `functional_tests/test_plugin_outbound_destination_security.py` executes + route/merge behavior and installed Yamcs, Tableau and MCP integration seams. + It covers private/public compatibility, reverse-proxy headers, unchanged TLS + settings, authoring/type denial and saved-credential retargeting. +- Existing action payload, shared-secret, connector, route policy and Cosmos + owner-runtime suites check preservation of the established contracts. + +Before the fix, forbidden targets could reach unguarded request clients and an +edited destination could inherit a saved key. After the fix, those regression +paths stop before a forbidden connection or retained-credential request, while +the positive enterprise-private and Yamcs reverse-proxy cases remain valid. + +See the [connection compatibility guide]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +for the user/admin-facing impact matrix. diff --git a/docs/guides/workspace-agents-and-actions.md b/docs/guides/workspace-agents-and-actions.md index a5c4fbaee..8274af45d 100644 --- a/docs/guides/workspace-agents-and-actions.md +++ b/docs/guides/workspace-agents-and-actions.md @@ -4,7 +4,7 @@ title: "Build agents and actions in My Workspace" description: "Find an available agent or configure a reusable assistant and its approved tools in V2." section: "Guides" audience: user -version: "0.261.432" +version: "0.261.435" --- ## What this does @@ -164,6 +164,42 @@ does not run the action. If a test fails, resolve the connection or permission problem rather than assuming that a successful configuration save proves the remote service is reachable. +### Connection destinations and compatibility + +Implemented in version: **0.261.435** (`application/single_app/config.py`). + +Configurable HTTP actions, Smart HTTP, and remote MCP connections check their +actual destination before opening a service connection. Public services and +legitimate private FQDNs/IPs remain supported, including on-premises services and +Private Link-style DNS. There is no new administrator destination allowlist. +Existing stricter governance policies still apply. + +Localhost, loopback, link-local, reserved non-host addresses and cloud +infrastructure destinations are not valid action targets. A hostname resolving +to one of those addresses is also refused. DNS checks are bound to the addresses +used by the client, including new connections after redirects or reconnects. +This does not change the application's own Azure identity authentication. + +| Existing setup | What to expect | +|---|---| +| An unchanged public or reachable private service | No mandatory re-save or credential reset. | +| Yamcs behind a remote reverse proxy | Basic proxy authentication remains supported. The remote proxy may use localhost for its own upstream. | +| A Server URL pointing to localhost on SimpleChat | The request is refused. In App Service, localhost means the worker/container, not an operator's workstation or an on-premises server. | +| A service redirecting to another origin | Configure the final service URL. Same-origin redirects and default-port HTTP-to-HTTPS upgrades remain supported; credentials and request bodies are not forwarded to a different origin. | +| An action edited to use a different connector, host, scheme or port | Re-enter or explicitly clear retained credentials before testing or saving. Unchanged-destination credential rotation continues normally. | + +Connection tests and discovery require the corresponding action-management and +type permissions. Ordinary execution of an authorized shared action does not +require its caller to become an editor. + +Yamcs's reverse-proxy authentication is not a deployment-wide forward proxy. +The guarded clients explicitly refuse HTTP(S) forward-proxy routing rather +than silently bypassing the proxy or allowing it to bypass destination checks. +Deployments using such routing need separate compatibility review before upgrading. +Proxy addresses and credentials are not automatically migrated. + +### Personal credentials for shared actions + Since **0.261.333**, shared Yamcs actions can request each invoking user's personal service or proxy identity. The credential is entered in a private chat card, not copied into the shared action or agent. These sources preserve diff --git a/docs/reference/actions/mcp.md b/docs/reference/actions/mcp.md index 3a8d81191..2bb01539a 100644 --- a/docs/reference/actions/mcp.md +++ b/docs/reference/actions/mcp.md @@ -56,6 +56,16 @@ saves the action. Discovery updates the draft's approved tool metadata; saving remains a separate decision. A successful probe does not qualify a tool's actual output or authorize replaying a failed operation. +Since **0.261.435**, all three remote transports also enforce a mandatory +destination safety boundary, even when the optional destination allowlist is +off. A public/private FQDN may be used, but its actual connection cannot reach +loopback, link-local or cloud infrastructure addresses. Existing stricter MCP +governance remains in force. Discovery/testing needs the corresponding editing +permission, and changing a stored action's connection origin cannot silently +carry its old credentials to the new server. +See [connection compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +for existing-action, redirect and proxy considerations. + ### Understanding tool execution outcomes As of **0.261.371**, an MCP server's protocol-level tool error is recorded as a diff --git a/docs/reference/actions/openapi.md b/docs/reference/actions/openapi.md index bef90ee9f..0d9dba2bd 100644 --- a/docs/reference/actions/openapi.md +++ b/docs/reference/actions/openapi.md @@ -55,6 +55,15 @@ An OpenAPI connection test loads the specification and probes the base URL; it does not call a selected operation or prove an agent can use it. Qualify the intended operation separately and inspect its actual tool result. +Since **0.261.435**, connection tests and operation requests use a destination +guard that permits legitimate public/private services but refuses localhost, +loopback, link-local and cloud infrastructure targets, including DNS aliases. +Tests check the base response headers without downloading an unbounded page. +Changing the connection origin cannot silently reuse a stored masked credential. +Use the final API URL if the service redirects across origins. +See [connection compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +before upgrading an existing integration. + ## Example prompts - "List the available API operation IDs for this action." diff --git a/docs/reference/actions/rocksdb.md b/docs/reference/actions/rocksdb.md index 52d07c0d0..d60ba234b 100644 --- a/docs/reference/actions/rocksdb.md +++ b/docs/reference/actions/rocksdb.md @@ -29,6 +29,22 @@ Set Service Base URL, Authentication Scheme, Column Family, Read-Only, encodings Shared wizard steps: [Common action setup steps](../#common-action-setup-steps). +## Connection destinations + +Implemented in version: **0.261.435** (`application/single_app/config.py`). + +Private service FQDNs and private IPs remain supported when reachable from +SimpleChat. Connection tests and runtime operations now refuse loopback, +link-local and cloud infrastructure destinations, including DNS aliases and +redirects to those addresses. Localhost refers to the SimpleChat host, not +an operator's workstation. + +Unchanged actions keep their credentials. When moving a service to a different +origin, re-enter or clear its stored token rather than carrying a masked value +to the new server. Connection probes read a bounded health/scan response. +See [connection compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +for existing-action upgrade considerations. + ## Related - [Actions reference index]({{ '/reference/actions/' | relative_url }}) diff --git a/docs/reference/actions/smart-http.md b/docs/reference/actions/smart-http.md index bdd00efa2..22e54fe58 100644 --- a/docs/reference/actions/smart-http.md +++ b/docs/reference/actions/smart-http.md @@ -14,6 +14,13 @@ audience: user Smart HTTP fetches web content with size management and text extraction. It supports HTML, JSON, and PDF URLs. PDFs can be processed through Document Intelligence, and large documents can be summarized into model-sized content with transparency about original size, chunking, and reduction. +Since **0.261.435**, GET and POST requests enforce the same destination guard as +configurable HTTP actions. Reachable public and enterprise private services +remain eligible; localhost, loopback, link-local and cloud infrastructure +addresses are refused, including through DNS and redirects. A missing optional +dependency no longer causes a fallback to an unguarded HTTP plugin. +See [connection compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility). + ## Why and when to use it Use Smart HTTP when the user gives a URL and the agent needs page or PDF content, especially when content may be large. Do not use it for APIs with structured operations; OpenAPI is safer. Do not use it for broad research over many sources; use Deep Research or web search controls when enabled. Be careful with private or sensitive URLs because the action fetches external content. diff --git a/docs/reference/actions/yamcs.md b/docs/reference/actions/yamcs.md index 66180f80f..c1ccba8f3 100644 --- a/docs/reference/actions/yamcs.md +++ b/docs/reference/actions/yamcs.md @@ -49,6 +49,30 @@ HTTP Basic `Authorization` header on every request, which satisfies the proxy an consumed before Yamcs sees it. Leave it off when you reach Yamcs directly, such as a local simulator, so no unnecessary credential is sent. +### Destination checks and existing proxy configurations + +Implemented in version: **0.261.435** (`application/single_app/config.py`). + +The action still connects to the configured Yamcs/proxy Server URL; enabling +reverse-proxy authentication adds credentials, not a localhost routing hop. +A reachable private proxy FQDN remains valid. Apache forwarding to localhost on +its own machine is not the same as SimpleChat requesting localhost. + +The destination guard refuses loopback, link-local and cloud infrastructure +targets, even when DNS or a redirect hides the address. In Azure App Service, +a localhost Server URL points to the SimpleChat worker/container, not a remote +Yamcs server or simulator. Expose a simulator through an appropriate reachable +service endpoint rather than treating an operator's localhost as a server URL. + +Unchanged connections retain their credentials, supported Verify TLS behavior, +timeouts and reusable/personal identity sources. No Authentication plus proxy +Basic auth and API Key plus proxy Basic auth remain supported. Changing the +connection origin requires explicit credential replacement or clearing; rotating +credentials at the same origin does not. + +See [connection destinations and compatibility]({{ '/guides/workspace-agents-and-actions/' | relative_url }}#connection-destinations-and-compatibility) +for redirect and deployment-wide forward-proxy upgrade considerations. + ### Which authentication methods it can be combined with HTTP Basic authentication uses the `Authorization` header, and so do two of the Yamcs diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index 7fa7bef0e..a35ecb612 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -7212,36 +7212,38 @@ }, "functional_tests/test_yamcs_action_plugin.py::FakeConfigCosmosContainer": { "boundary": { - "sha256": "4018e342fac645ac62a882cfc9e1c46c2102e9ad98f53bc5c6d115bf958f8558", + "sha256": "187d877dea5503f3170edf943b0649002ffd2c1b6cde7472d83feccf45f3c1e2", "methods": [ - "read_item", - "upsert_item" + "read_item" ], "kind": "class", - "inherited_bases": [], - "imports_sha256": "c627a61e49bf8d17af74ea2e167c6ee7539348e10ea5cffa9c46b26610908c10" + "inherited_bases": [ + "CosmosContainer" + ], + "imports_sha256": "9c59f1321c626e81c32b5864e955d3e1abaa18b2e2c9c78328c867512bb8f3d4" }, - "classification": "legacy-inline", - "contract": "Unchanged inline Cosmos-shaped test container outside the migrated shared defaults. Its test-specific response behavior is an explicit staged-migration baseline, not blanket runtime SDK proof.", + "classification": "sdk-default", + "contract": "The bootstrap container inherits the shared SDK-default CosmosContainer. Its deterministic settings read returns native CosmosDict responses; inherited writes, ETags, scoped query rows and stale conditional-write refusal retain the shared SDK contract.", "tests": [ - "functional_tests/test_yamcs_action_plugin.py" + "functional_tests/test_outbound_destination_policy.py::test_yamcs_bootstrap_doubles_use_native_sdk_contracts_in_isolated_processes" ] }, "functional_tests/test_yamcs_basic_auth.py::FakeConfigCosmosContainer": { "boundary": { - "sha256": "4018e342fac645ac62a882cfc9e1c46c2102e9ad98f53bc5c6d115bf958f8558", + "sha256": "187d877dea5503f3170edf943b0649002ffd2c1b6cde7472d83feccf45f3c1e2", "methods": [ - "read_item", - "upsert_item" + "read_item" ], "kind": "class", - "inherited_bases": [], - "imports_sha256": "f117480d3a78090c5ace2f23e79f1303a1bdc7b0fca5cb570ce9eb9cffa2c0a5" + "inherited_bases": [ + "CosmosContainer" + ], + "imports_sha256": "f5580c8884e0a6ff58b9ba9f869b30ceccbfe265597ee679bbc585f63e7fe469" }, - "classification": "legacy-inline", - "contract": "Unchanged inline Cosmos-shaped test container outside the migrated shared defaults. Its test-specific response behavior is an explicit staged-migration baseline, not blanket runtime SDK proof.", + "classification": "sdk-default", + "contract": "The bootstrap container inherits the shared SDK-default CosmosContainer. The runtime probe verifies native point responses, updated ETags, plain scoped query rows and preservation after a stale conditional write.", "tests": [ - "functional_tests/test_yamcs_basic_auth.py" + "functional_tests/test_outbound_destination_policy.py::test_yamcs_bootstrap_doubles_use_native_sdk_contracts_in_isolated_processes" ] } }, @@ -7460,9 +7462,7 @@ "functional_tests/test_workflow_result_followup.py::Messages": "36d789c1247b2f2488093045647a6670d1f7f24e548aac09f24650f90e292b6d", "functional_tests/test_workflow_runtime_integration.py::ServiceContainer": "cffcfe612c3e5963875a758197331c393c580d29adec7191eefecd93e0533999", "functional_tests/test_workflow_structured_flow.py::JournalContainer": "22617222c341f976e6438a50dad225229aba459fb17ae631c2da34a931e488f6", - "functional_tests/test_workflow_task_result_handoff.py::RunItemsContainer": "c3b26a1b2428fe15593ce4f45db10df0e208217f4dfee7f200f74d75650b3dde", - "functional_tests/test_yamcs_action_plugin.py::FakeConfigCosmosContainer": "484348ec19021ba279aa44c152721f7d0f8da6eb16f72afca92c0035156e5588", - "functional_tests/test_yamcs_basic_auth.py::FakeConfigCosmosContainer": "98e363d239d8d8432772c3921fe34da109d39895a30ae200ce76823c0e968589" + "functional_tests/test_workflow_task_result_handoff.py::RunItemsContainer": "c3b26a1b2428fe15593ce4f45db10df0e208217f4dfee7f200f74d75650b3dde" }, "owners": { "application/single_app/app_settings_cache.py::_append_stream_session_event_fallback": { @@ -26729,10 +26729,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Personal name lookup consumes plain query rows through id/name mapping access. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Personal name lookup consumes plain query rows through id/name mapping access. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26752,10 +26752,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Global name lookup consumes plain query rows through id mapping access. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Global name lookup consumes plain query rows through id mapping access. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26780,10 +26780,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Group name/merge lookup consumes plain query rows and preserves explicit group partition routing. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Group name/merge lookup consumes plain query rows and preserves explicit group partition routing. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26813,10 +26813,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Cleanup iterates plain query-row mappings and only compares stored string references; no strict response contract. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Cleanup iterates plain query-row mappings and only compares stored string references; no strict response contract. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26836,10 +26836,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Post-commit conditional patch returns the SDK mapping with its new ETag; the editor projection consumes mappings and constructs plain browser records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Post-commit conditional patch returns the SDK mapping with its new ETag; the editor projection consumes mappings and constructs plain browser records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_reminder_patch_preserves_sdk_response_and_etag", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26859,10 +26859,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26882,10 +26882,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26905,10 +26905,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Conditional delete response is unused; its authorized input record and ETag are mapping-compatible, with SDK ETag/conflict behavior retained. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26933,10 +26933,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26956,10 +26956,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -26984,10 +26984,10 @@ } ], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "Plain query rows are read through mapping guards and projected before browser JSON; no strict point-response consumer. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27007,10 +27007,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27030,10 +27030,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27053,10 +27053,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27076,10 +27076,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK point mapping is checked through get(), retains its ETag and authorized partition, and remains mapping-compatible through editor projection/merge. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27104,10 +27104,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27132,10 +27132,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -27160,10 +27160,10 @@ ], "queries": [], "batches": [], - "module_sha256": "aa067e8f3673492d50165f24ca184812f8e70e4d67cde0dfae9b64bf2f527b67" + "module_sha256": "94eaaca4a036c2b65909e181df4a3cb8ae84e6377190fb27fb0f6bf4bf503bd9" }, "classification": "sdk-compatible", - "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination.", + "contract": "SDK create/replace mapping preserves conditional ETags and credential bindings; downstream reminder/projection helpers consume mappings and build plain editor records. Merge integration: this storage implementation remains AST-identical to the reviewed credential owner. Shared projection/classic-write consumers are now combined with binding readiness. Installed-SDK editor CRUD/CAS, reminder, and raw/cleaned readiness regressions exercise that combination. Combined outbound-action/agent-credential integration review: this storage owner body and call shapes match Development; real-owner SDK and shared-agent regressions were rerun with both protections present.", "tests": [ "functional_tests/test_agent_credential_owner_runtime.py::test_native_agent_owner_sdk_contracts", "functional_tests/test_shared_agent_secret_responses.py::test_shared_agent_raw_and_cleaned_projections_keep_real_runtime_readiness", @@ -32077,7 +32077,7 @@ }, "application/single_app/route_backend_plugins.py::edit_plugin": { "boundary": { - "sha256": "33d4fff6f75f78a7b47db4aab6feac61be6aa97534c858376aee87a74509195c", + "sha256": "a4adb7ce73e1ddd340cd0ad8e11fae911390bde4fed9f6108229300ad9219b35", "points": [ { "method": "read_item", @@ -32087,20 +32087,20 @@ ], "queries": [], "batches": [], - "module_sha256": "d216807e4d1e88608cf19390c10542d65dba8094782ad9a7f3069cd7809b5ef1" + "module_sha256": "ede4197b34141f5becd0af22b3b9f246028bf45b3e70d9a569dd5efd12c02752" }, "classification": "sdk-compatible", - "contract": "The unchanged admin route passes the exact scoped CosmosDict point read to the mapping-compatible legacy-update validator. Renewed runtime evidence verifies identity/ETag preservation and refuses a missing or mismatched exact record before invoking a writer.", - "evidence_scope": "owner-runtime", + "contract": "The admin route still passes its exact scoped CosmosDict point read to the mapping-compatible legacy validator. Added connection-retention validation consumes authorized record fields without a new strict point-document contract. Renewed real-owner SDK cases preserve IDs/ETags and refuse missing or mismatched exact records before writing.", "tests": [ "functional_tests/test_action_route_cosmos_response_contract.py::test_action_route_missing_exact_point_read_cannot_fall_back_to_a_name", "functional_tests/test_action_route_cosmos_response_contract.py::test_action_route_point_reads_reach_real_legacy_validator_as_sdk_documents", "functional_tests/test_plugin_routes_cosmos_contract.py::test_global_legacy_update_consumes_sdk_point_response" - ] + ], + "evidence_scope": "owner-runtime" }, "application/single_app/route_backend_plugins.py::set_user_plugins": { "boundary": { - "sha256": "2a84bcceb1cea62cb4c5e3c20803005f63a499e4def57c408eef93e1e8e04aa9", + "sha256": "d22151658ecfb4e5314062dbd3fbdfa90f428595e18fbebaa6340aaa717a3b0a", "points": [ { "method": "read_item", @@ -32110,20 +32110,20 @@ ], "queries": [], "batches": [], - "module_sha256": "d216807e4d1e88608cf19390c10542d65dba8094782ad9a7f3069cd7809b5ef1" + "module_sha256": "ede4197b34141f5becd0af22b3b9f246028bf45b3e70d9a569dd5efd12c02752" }, "classification": "sdk-compatible", - "contract": "The unchanged classic collection save passes its actor-partitioned SDK point mapping to the real legacy-update validator. Renewed runtime coverage preserves fields/ETags and refuses foreign or missing exact IDs before any writer; shared list projections do not alter this storage consumer.", - "evidence_scope": "owner-runtime", + "contract": "The classic collection save retains its actor-partitioned SDK point mapping and real legacy-update validator. Added name disambiguation and destination checks consume mapping fields, not a strict copied SDK document. Renewed runtime evidence preserves IDs/ETags and refuses foreign or missing exact records before writers.", "tests": [ "functional_tests/test_action_route_cosmos_response_contract.py::test_action_route_missing_exact_point_read_cannot_fall_back_to_a_name", "functional_tests/test_action_route_cosmos_response_contract.py::test_action_route_point_reads_reach_real_legacy_validator_as_sdk_documents", "functional_tests/test_plugin_routes_cosmos_contract.py::test_personal_legacy_update_consumes_sdk_point_response" - ] + ], + "evidence_scope": "owner-runtime" }, "application/single_app/route_backend_plugins.py::test_cosmos_connection": { "boundary": { - "sha256": "b0a291494272cabf99d1c4e6c442e397453712b28ad1434291b0192fe717120e", + "sha256": "5d45f4562dbb127855097b923cb1fd96ed445060882d84187b32acbc9a4a3db7", "points": [], "queries": [ { @@ -32133,15 +32133,15 @@ } ], "batches": [], - "module_sha256": "d216807e4d1e88608cf19390c10542d65dba8094782ad9a7f3069cd7809b5ef1" + "module_sha256": "ede4197b34141f5becd0af22b3b9f246028bf45b3e70d9a569dd5efd12c02752" }, "classification": "response-unused", - "contract": "The unchanged connection probe drains scalar SELECT TOP 1 VALUE c.id query rows only to verify permission; it never copies them into a document contract. Renewed owner-runtime coverage verifies SDK metadata handling, lazy query draining, routing and response-hook headers.", - "evidence_scope": "owner-runtime", + "contract": "The authorized probe drains scalar SELECT TOP 1 VALUE c.id query rows only to verify permission; it never copies them into a document contract. Added authoring/credential checks precede the query and leave this consumer unchanged. Renewed SDK runtime cases verify metadata handling, lazy draining, routing and response-hook headers.", "tests": [ "functional_tests/test_action_route_cosmos_response_contract.py::test_cosmos_connection_drains_query_rows_without_treating_them_as_documents", "functional_tests/test_plugin_routes_cosmos_contract.py::test_cosmos_connection_consumes_lazy_query_results" - ] + ], + "evidence_scope": "owner-runtime" }, "application/single_app/route_backend_public_documents.py::register_route_backend_public_documents.api_bulk_tag_public_documents": { "boundary": { diff --git a/functional_tests/test_action_route_cosmos_response_contract.py b/functional_tests/test_action_route_cosmos_response_contract.py index a32b4dd06..fbd252292 100644 --- a/functional_tests/test_action_route_cosmos_response_contract.py +++ b/functional_tests/test_action_route_cosmos_response_contract.py @@ -1,7 +1,7 @@ # test_action_route_cosmos_response_contract.py """Runtime evidence for unchanged Cosmos owners in the edited action route module. -Version: 0.261.354 +Version: 0.261.435 Implemented in: 0.261.354 The inventory invalidates module-wide source-only evidence after any route-module @@ -22,6 +22,7 @@ import pytest import json_schema_validation as validation +from functions_workspace_authoring import validate_action_connection_update from test_support.agent_delegation import execute_functions from test_support.m365 import CosmosContainer @@ -58,6 +59,8 @@ def routes(monkeypatch): "get_merged_plugin_settings": lambda kind, payload, directory: payload, "_validate_action_identity_for_scope": Mock(), "_enforce_mcp_destination_policy": Mock(), + "validate_action_connection_update": validate_action_connection_update, + "_authorize_action_test_scope": Mock(return_value=("personal", "actor")), "WORKSPACE_IDENTITY_SCOPE_GLOBAL": "global", "McpDestinationPolicyError": type("McpDestinationPolicyError", (ValueError,), {}), "save_personal_action": Mock(), "save_global_action": Mock(), @@ -121,7 +124,7 @@ def test_cosmos_connection_drains_query_rows_without_treating_them_as_documents( "validate_azure_cosmos_endpoint": lambda endpoint: endpoint, "_load_existing_plugin_for_test": lambda *args, **kwargs: None, "_resolve_plugin_secret_context": lambda *args: ("actor", "personal"), - "_resolve_secret_value_for_action_test": lambda value, *args: value, + "_resolve_secret_value_for_action_test": lambda value, *args, **kwargs: value, "ACTION_AUTH_SECRET_SOURCES": {"action"}, "ui_trigger_word": "Stored_In_KeyVault", }) payload = { diff --git a/functional_tests/test_keyvault_plugin_secret_scope_enforcement.py b/functional_tests/test_keyvault_plugin_secret_scope_enforcement.py index 26caf9d39..466b3f610 100644 --- a/functional_tests/test_keyvault_plugin_secret_scope_enforcement.py +++ b/functional_tests/test_keyvault_plugin_secret_scope_enforcement.py @@ -1,7 +1,7 @@ # test_keyvault_plugin_secret_scope_enforcement.py """ Functional test for Key Vault plugin secret scope enforcement. -Version: 0.261.096 +Version: 0.261.435 Implemented in: 0.241.011; 0.241.022; 0.250.121; 0.261.096 This test ensures plugin Key Vault references are validated against the @@ -15,6 +15,7 @@ import os import re import sys +from unittest.mock import patch ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -165,6 +166,9 @@ def test_store_plugin_secret_reference_rejects_cross_scope_secret_names(): def test_runtime_plugin_loader_blanks_secret_fields_on_scope_mismatch(): """Verify the runtime loader refuses to resolve foreign plugin secret references.""" print("🔍 Testing runtime plugin secret resolution hardening...") + # Standalone execution has no application import path; these contracts are bootstrap-free. + with patch.object(sys, "path", [os.path.join(ROOT_DIR, "application", "single_app"), *sys.path]): + from functions_action_manifest import copy_action_manifest, is_mcp_action namespace, _ = load_functions( SK_LOADER_FILE, @@ -184,6 +188,8 @@ def test_runtime_plugin_loader_blanks_secret_fields_on_scope_mismatch(): "log_event": lambda *args, **kwargs: None, "logging": logging, "debug_print": lambda *args, **kwargs: None, + "is_mcp_action": is_mcp_action, + "copy_action_manifest": copy_action_manifest, }, ) @@ -227,6 +233,7 @@ def fake_resolver(value, scope_value=None, scope=None, allowed_sources=None, con "validate_secret_name_dynamic": lambda value: True, "resolve_secret_reference_for_context": fake_resolver, "ACTION_ADDITIONAL_SECRET_SOURCES": {"action-addset"}, + "WorkspaceAuthoringValidation": ValueError, }, ) @@ -236,6 +243,7 @@ def fake_resolver(value, scope_value=None, scope=None, allowed_sources=None, con "connection_string", scope_value="user-123", scope="user", + stored_value="user-123--action-addset--user--plugin-connection-string", ) assert resolved_value == "resolved-secret" @@ -243,6 +251,17 @@ def fake_resolver(value, scope_value=None, scope=None, allowed_sources=None, con assert captured["scope"] == "user" assert captured["allowed_sources"] == {"action-addset"} assert captured["context_label"] == "SQL field 'connection_string'" + captured.clear() + try: + resolve_sql_secret( + "user-123--action-addset--user--another-action-password", + "password", scope_value="user-123", scope="user", + ) + except ValueError: + pass + else: + raise AssertionError("A same-scope reference still requires its original action field.") + assert not captured try: resolve_sql_secret("***REDACTED***", "password", scope_value="user-123", scope="user") diff --git a/functional_tests/test_mcp_action_route_security.py b/functional_tests/test_mcp_action_route_security.py index 281c56938..e573b136f 100644 --- a/functional_tests/test_mcp_action_route_security.py +++ b/functional_tests/test_mcp_action_route_security.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional tests for MCP discovery and connection-test authorization ordering. -Version: 0.261.428 +Version: 0.261.435 Implemented in: 0.261.029 Executes real route/helper bodies with Flask requests and mocked I/O boundaries. @@ -22,6 +22,7 @@ import time import unittest import uuid +from copy import deepcopy from unittest.mock import AsyncMock, Mock, patch from flask import Flask, jsonify, request, session @@ -38,7 +39,11 @@ ) import functions_mcp_operations as operations import json_schema_validation as validation -from functions_workspace_authoring import WorkspaceAuthoringValidation +from functions_workspace_authoring import ( + WorkspaceAuthoringValidation, apply_group_action_write, editor_error_response, + legacy_editor_write_body, validate_action_connection_update, +) +from functions_databricks_operations import DATABRICKS_PLUGIN_TYPE, DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE from test_mcp_legacy_stdio_management import GROUP, OWNER, action_services, remote @@ -54,6 +59,9 @@ "_resolve_secret_value_for_action_test", "_hydrate_mcp_custom_headers_for_test", "_prepare_action_test_manifest", + "_authorize_action_test_scope", + "_resolve_group_for_test", + "_prepare_group_action_payload", "_run_action_connection_test", "test_mcp_action_connection", "discover_mcp_tools", @@ -84,7 +92,15 @@ def setUp(self): presets = types.ModuleType("functions_mcp_presets") presets.normalize_mcp_preset_id = lambda value: value or "generic" presets.mcp_server_preset_exists = lambda value: value == "generic" - self.modules = patch.dict(sys.modules, {"functions_mcp_presets": presets}) + keyvault = types.ModuleType("functions_keyvault") + keyvault.redact_plugin_secret_values = deepcopy + keyvault.validate_secret_name_dynamic = lambda value: value.startswith("kv-") + appinsights = types.ModuleType("functions_appinsights") + appinsights.log_event = Mock() + self.modules = patch.dict(sys.modules, { + "functions_mcp_presets": presets, "functions_keyvault": keyvault, + "functions_appinsights": appinsights, + }) self.modules.start() self.addCleanup(self.modules.stop) logger = types.ModuleType("functions_appinsights") @@ -106,6 +122,7 @@ def setUp(self): "logging": logging, "time": time, "uuid": uuid, + "deepcopy": deepcopy, "jsonify": jsonify, "request": request, "session": session, @@ -113,6 +130,8 @@ def setUp(self): "debug_print": Mock(), "CHART_PLUGIN_TYPE": "chart", "MSGRAPH_PLUGIN_TYPE": "msgraph", + "DATABRICKS_PLUGIN_TYPE": DATABRICKS_PLUGIN_TYPE, + "DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE": DATABRICKS_LEGACY_TABLE_PLUGIN_TYPE, "WORKSPACE_IDENTITY_SCOPE_PERSONAL": "personal", "WORKSPACE_IDENTITY_SCOPE_GROUP": "group", "WORKSPACE_IDENTITY_SCOPE_GLOBAL": "global", @@ -120,11 +139,20 @@ def setUp(self): "LegacyActionConflictError": LegacyActionConflictError, "LegacyActionSourceUpdateError": LegacyActionSourceUpdateError, "WorkspaceAuthoringValidation": WorkspaceAuthoringValidation, + "validate_action_connection_update": validate_action_connection_update, + "apply_group_action_write": apply_group_action_write, + "legacy_editor_write_body": legacy_editor_write_body, + "editor_error_response": Mock(wraps=editor_error_response), "SecretReturnType": types.SimpleNamespace(NAME="name", VALUE="value"), "get_current_user_id": lambda: session["user"]["oid"], "require_active_group": lambda user_id: "group-a", - "resolve_delegation_group_scope": lambda user_id, group_id: group_id or "group-a", "assert_group_role": Mock(), + "ensure_editor_access": Mock(), + "resolve_delegation_group_scope": lambda user_id, group_id=None: group_id or "group-a", + "find_group_by_id": lambda group_id: {"id": group_id}, + "get_user_role_in_group": lambda group, user_id: "Owner", + "group_action_management_operations": lambda *args: {"create", "edit", "delete", "test"}, + "get_group_workflow_management_roles": lambda settings: ("Owner", "Admin"), "get_personal_action": self.get_personal, "get_group_action": Mock(return_value=None), "delete_group_action": Mock(return_value=True), @@ -257,7 +285,11 @@ def test_group_and_admin_saves_reject_metadata_only_stdio(self): ) response = self.post(route, json=payload) self.assertEqual(response.status_code, 400) - self.assertEqual(response.get_json()["error_type"], "mcp_stdio_removed") + if route == "/group": + rejected = self.namespace["editor_error_response"].call_args.args[0] + self.assertIsInstance(rejected, manifests.McpStdioRemovedError) + else: + self.assertEqual(response.get_json()["error_type"], "mcp_stdio_removed") self.assert_no_execution_or_credentials() def test_group_cleanup_exception_is_limited_to_retired_owned_actions(self): diff --git a/functional_tests/test_outbound_destination_policy.py b/functional_tests/test_outbound_destination_policy.py new file mode 100644 index 000000000..4e4fdeb53 --- /dev/null +++ b/functional_tests/test_outbound_destination_policy.py @@ -0,0 +1,407 @@ +# test_outbound_destination_policy.py +"""Destination and real-client transport regressions for configurable actions. + +Version: 0.261.436 +Implemented in: 0.261.435 + +All DNS and external connection boundaries are synthetic. Enterprise private +destinations remain valid; loopback, metadata, rebinding and retargeting do not. +""" + +import asyncio +import os +import socket +import ssl +import subprocess +import sys +from io import BytesIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import aiohttp +import pytest +import requests +import urllib3.connection + +import functions_outbound_destinations as policy +import functions_outbound_http as transport + + +@pytest.mark.parametrize("url", [ + "http://localhost", "https://service.localhost", "http://127.0.0.2", + "https://169.254.169.254", "http://168.63.129.16", "http://100.100.100.200", + "http://[::1]", "http://[::ffff:127.0.0.1]", "http://[fd00:ec2::254]", + "http://0.1.2.3", "http://224.0.0.1", "http://127.1", "http://2130706433", + "http://0x7f000001", "http://0177.0.0.1", "file:///etc/hosts", + "https://user:password@api.example.test", "https://api.example.test\\@localhost", +]) +def test_forbidden_literal_targets_are_rejected(url): + with pytest.raises(policy.OutboundDestinationError): + policy.normalize_outbound_url(url) + + +@pytest.mark.parametrize("changes", [ + {"endpoint": "https://[invalid"}, {"additionalFields": []}, +]) +def test_invalid_connection_signatures_have_stable_validation_errors(changes): + with pytest.raises(policy.OutboundDestinationError): + policy.action_connection_signature({ + "type": "openapi", "endpoint": "https://service.corp.internal", **changes, + }) + + +@pytest.mark.parametrize("url", [ + "https://yamcs.corp.internal:8443", "http://192.168.10.20:8090", + "https://10.2.3.4", "https://172.20.30.40", "https://[fd12::1234]", + "https://service.public.example", "wss://mcp.corp.internal:9443", +]) +def test_private_and_public_service_targets_remain_valid(url): + target = policy.normalize_outbound_url(url) + assert target.host + + +def dns_answer(address): + family = socket.AF_INET6 if ":" in address else socket.AF_INET + return family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (address, 443) + + +@pytest.mark.parametrize("addresses", [ + ["127.0.0.1"], ["169.254.169.254"], ["::ffff:127.0.0.1"], + ["192.168.1.20", "169.254.169.254"], ["fd12::1234", "::1"], +]) +def test_all_dns_answers_must_be_safe(monkeypatch, addresses): + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: list(map(dns_answer, addresses))) + with pytest.raises(policy.OutboundDestinationError): + policy.resolve_outbound_addresses("service.corp.internal", 443) + + +def test_requests_connects_to_vetted_ip_without_changing_tls_hostname(monkeypatch): + resolver = Mock(return_value=[dns_answer("192.168.4.20")]) + connect = Mock(return_value=Mock()) + monkeypatch.setattr(socket, "getaddrinfo", resolver) + monkeypatch.setattr(transport, "create_connection", connect) + session = transport.create_guarded_session() + try: + adapter = session.get_adapter("https://") + pool = adapter.poolmanager.connection_from_url("https://yamcs.corp.internal") + connection = pool._new_conn() + connection._new_conn() + assert connect.call_args.args[0] == ("192.168.4.20", 443) + assert connection.host == "yamcs.corp.internal" + assert pool.host == "yamcs.corp.internal" + assert resolver.call_args.args[:2] == ("yamcs.corp.internal", 443) + finally: + session.close() + + +def test_rebinding_at_requests_connection_is_rejected(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", Mock(side_effect=[ + [dns_answer("192.168.4.20")], [dns_answer("127.0.0.1")], + ])) + addresses = policy.resolve_outbound_addresses("service.corp.internal", 443) + assert addresses == ("192.168.4.20",) + connect = Mock() + monkeypatch.setattr(transport, "create_connection", connect) + with pytest.raises(policy.OutboundDestinationError): + transport._GuardedHTTPSConnection("service.corp.internal")._new_conn() + connect.assert_not_called() + + +@pytest.mark.parametrize("destination", [ + "http://127.0.0.1/status", "http://169.254.169.254", "https://different.example.test", + "http://service.corp.internal/status", +]) +def test_redirects_cannot_retarget_or_downgrade(destination): + with pytest.raises(policy.OutboundDestinationError): + policy.validate_outbound_redirect("https://service.corp.internal/start", destination) + + +def test_requests_rejects_redirect_before_following_or_forwarding_credentials(): + session = transport.create_guarded_session() + response = requests.Response() + response.status_code = 302 + response.headers["Location"] = "https://other.example.test" + response.request = requests.Request( + "GET", "https://yamcs.corp.internal", headers={"X-API-Key": "synthetic-secret"}, + ).prepare() + response.close = Mock() + try: + with pytest.raises(policy.OutboundDestinationError): + session.get_redirect_target(response) + response.close.assert_called_once() + finally: + session.close() + + +def test_safe_redirect_and_default_port_tls_upgrade_are_preserved(): + same = policy.validate_outbound_redirect("https://service.corp.internal/a", "/b") + upgraded = policy.validate_outbound_redirect("http://service.corp.internal/a", "https://service.corp.internal/b") + assert same == "https://service.corp.internal/b" + assert upgraded == same + + +def test_httpx_connects_to_vetted_address(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [dns_answer("10.2.3.4")]) + backend = Mock(connect_tcp=AsyncMock(return_value=Mock())) + guarded = transport._GuardedAsyncBackend(backend) + asyncio.run(guarded.connect_tcp("yamcs.corp.internal", 443)) + assert backend.connect_tcp.call_args.args[:2] == ("10.2.3.4", 443) + + +def test_httpx_rejects_forbidden_dns_before_tcp(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [dns_answer("169.254.169.254")]) + backend = Mock(connect_tcp=AsyncMock()) + with pytest.raises(policy.OutboundDestinationError): + asyncio.run(transport._GuardedAsyncBackend(backend).connect_tcp("service.example.test", 443)) + backend.connect_tcp.assert_not_called() + + +def test_aiohttp_checks_the_addresses_used_by_connector(monkeypatch): + original = AsyncMock(return_value=[{ + "hostname": "yamcs.corp.internal", "host": "127.0.0.1", "port": 443, + "family": socket.AF_INET, "proto": socket.IPPROTO_TCP, "flags": 0, + }]) + monkeypatch.setattr(aiohttp.TCPConnector, "_resolve_host", original) + + async def probe(): + async with transport.GuardedTCPConnector() as connector: + with pytest.raises(policy.OutboundDestinationError): + await connector._resolve_host("yamcs.corp.internal", 443) + + asyncio.run(probe()) + + +def test_authority_headers_cannot_override_the_service(): + with pytest.raises(policy.OutboundDestinationError): + policy.validate_outbound_headers("https://service.corp.internal", {"Host": "localhost"}) + + +def test_forward_proxy_is_rejected_explicitly_not_silently_disabled(monkeypatch): + monkeypatch.setattr(transport, "get_environ_proxies", lambda url: {"https": "http://proxy.example.test:3128"}) + with pytest.raises(policy.OutboundDestinationError, match="Forward-proxy"): + transport._reject_forward_proxy("https://service.corp.internal") + + +def test_requests_keeps_original_sni_and_enabled_verification(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [dns_answer("192.168.4.20")]) + sock = Mock() + connect = Mock(return_value=sock) + tls = Mock(return_value=SimpleNamespace(socket=sock, is_verified=True)) + monkeypatch.setattr(transport, "create_connection", connect) + monkeypatch.setattr(urllib3.connection, "_ssl_wrap_socket_and_match_hostname", tls) + connection = transport._GuardedHTTPSConnection("yamcs.corp.internal", cert_reqs=ssl.CERT_REQUIRED) + connection.connect() + assert connect.call_args.args[0] == ("192.168.4.20", 443) + assert tls.call_args.kwargs["server_hostname"] == "yamcs.corp.internal" + assert tls.call_args.kwargs["cert_reqs"] == ssl.CERT_REQUIRED + connection.close() + + +def test_full_httpx_request_preserves_host_sni_and_auth_on_private_connection(monkeypatch): + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [dns_answer("10.2.3.4")]) + monkeypatch.setattr(transport, "get_environ_proxies", lambda url: {}) + writes = [] + handshakes = [] + + class MemoryStream: + async def write(self, buffer, timeout=None): + writes.append(buffer) + + async def read(self, max_bytes, timeout=None): + return b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}" + + async def start_tls(self, ssl_context, server_hostname=None, timeout=None): + handshakes.append((ssl_context.verify_mode, server_hostname)) + return self + + async def aclose(self): + pass + + def get_extra_info(self, name): + return False if name == "is_readable" else None + + backend = Mock(connect_tcp=AsyncMock(return_value=MemoryStream())) + + async def probe(): + client = transport.create_guarded_httpx_client(headers={"Authorization": "Basic synthetic"}) + client._transport._pool._network_backend = transport._GuardedAsyncBackend(backend) + async with client: + response = await client.get("https://yamcs.corp.internal/status") + return response.status_code + + status = asyncio.run(probe()) + assert status == 200 + assert backend.connect_tcp.call_args.args[:2] == ("10.2.3.4", 443) + assert handshakes == [(ssl.CERT_REQUIRED, "yamcs.corp.internal")] + wire = b"".join(writes).lower() + assert b"host: yamcs.corp.internal" in wire + assert b"authorization: basic synthetic" in wire + + +def test_connection_probes_limit_body_reads_and_close_responses(monkeypatch): + response = requests.Response() + response.status_code = 200 + response.raw = BytesIO(b"x" * 100) + session = Mock() + session.__enter__ = Mock(return_value=session) + session.__exit__ = Mock(return_value=False) + session.request.return_value = response + close = Mock(wraps=response.close) + monkeypatch.setattr(response, "close", close) + monkeypatch.setattr(transport, "create_guarded_session", lambda: session) + with pytest.raises(policy.OutboundDestinationError, match="too large"): + transport.guarded_probe_request("GET", "https://service.corp.internal", max_bytes=8) + close.assert_called_once() + + +@pytest.mark.parametrize("header", ["service.corp.internal:443", "SERVICE.CORP.INTERNAL"]) +def test_equivalent_authority_headers_remain_usable(header): + target = policy.validate_outbound_headers("https://service.corp.internal/status", {"Host": header}) + assert target.host == "service.corp.internal" + + +def test_mapped_and_nat64_private_addresses_are_allowed_but_forbidden_targets_are_not(): + private = policy.normalize_outbound_url("https://[64:ff9b::a00:1]") + assert private.host == "64:ff9b::a00:1" + with pytest.raises(policy.OutboundDestinationError): + policy.normalize_outbound_url("https://[64:ff9b::7f00:1]") + + +def test_guarded_session_does_not_change_other_azure_sdk_or_requests_sessions(): + guarded = transport.create_guarded_session() + ordinary = requests.Session() + try: + guarded_pool = guarded.get_adapter("https://").poolmanager.connection_from_url("https://service.corp.internal") + ordinary_pool = ordinary.get_adapter("https://").poolmanager.connection_from_url("https://service.corp.internal") + guarded_connection = guarded_pool._new_conn() + ordinary_connection = ordinary_pool._new_conn() + assert isinstance(guarded_connection, transport._GuardedHTTPSConnection) + assert not isinstance(ordinary_connection, transport._GuardedHTTPSConnection) + finally: + guarded.close() + ordinary.close() + + +def test_guarded_requests_never_add_server_netrc_credentials(monkeypatch): + netrc = Mock(return_value=("ambient-user", "ambient-password")) + monkeypatch.setattr(requests.sessions, "get_netrc_auth", netrc) + session = transport.create_guarded_session() + try: + prepared = session.prepare_request(requests.Request("GET", "https://service.corp.internal")) + assert "Authorization" not in prepared.headers + response = requests.Response() + response.request = prepared + redirect = requests.Request("GET", "https://service.corp.internal/next").prepare() + session.rebuild_auth(redirect, response) + assert "Authorization" not in redirect.headers + netrc.assert_not_called() + explicit = session.prepare_request(requests.Request( + "GET", "https://service.corp.internal", auth=("explicit-user", "explicit-password"), + )) + assert explicit.headers["Authorization"].startswith("Basic ") + finally: + session.close() + + +def test_websocket_connects_to_vetted_private_ip_and_keeps_sni(monkeypatch): + # Import the optional protocol only for these connector tests. + import websockets.asyncio.client + + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [dns_answer("10.2.3.4")]) + monkeypatch.setattr(transport, "get_environ_proxies", lambda url: {}) + connection = SimpleNamespace(close=AsyncMock()) + opened = [] + + class OpenSocket: + def __init__(self, url, **kwargs): + opened.append((url, kwargs)) + + def __await__(self): + async def connected(): + return connection + return connected().__await__() + + monkeypatch.setattr(websockets.asyncio.client, "connect", OpenSocket) + + async def probe(): + connect = AsyncMock() + monkeypatch.setattr(asyncio.get_running_loop(), "sock_connect", connect) + async with transport.guarded_websocket_connection("wss://mcp.corp.internal/endpoint") as active: + assert active is connection + assert connect.call_args.args[1] == ("10.2.3.4", 443) + + asyncio.run(probe()) + assert opened[0][1]["server_hostname"] == "mcp.corp.internal" + assert opened[0][1]["proxy"] is None + assert opened[0][1]["sock"].fileno() == -1 + connection.close.assert_awaited_once() + + +def test_websocket_checks_dns_again_after_same_origin_redirect(monkeypatch): + import websockets.asyncio.client + from websockets.datastructures import Headers + from websockets.exceptions import InvalidStatus + from websockets.http11 import Response + + monkeypatch.setattr(transport, "get_environ_proxies", lambda url: {}) + monkeypatch.setattr(socket, "getaddrinfo", Mock(side_effect=[ + [dns_answer("10.2.3.4")], [dns_answer("169.254.169.254")], + ])) + + class RedirectSocket: + def __init__(self, url, **kwargs): + pass + + def __await__(self): + async def redirected(): + raise InvalidStatus(Response(302, "Found", Headers({"Location": "/new-endpoint"}), b"")) + return redirected().__await__() + + monkeypatch.setattr(websockets.asyncio.client, "connect", RedirectSocket) + + async def probe(): + connect = AsyncMock() + monkeypatch.setattr(asyncio.get_running_loop(), "sock_connect", connect) + with pytest.raises(policy.OutboundDestinationError): + async with transport.guarded_websocket_connection("wss://mcp.corp.internal/endpoint"): + pass + assert connect.await_count == 1 + + asyncio.run(probe()) + + +@pytest.mark.parametrize("module_name", ["test_yamcs_action_plugin", "test_yamcs_basic_auth"]) +def test_yamcs_bootstrap_doubles_use_native_sdk_contracts_in_isolated_processes(module_name): + # Legacy standalone modules bootstrap config at import; never leak their stubs into other suites. + script = r''' +import importlib +import sys +from azure.cosmos._cosmos_responses import CosmosDict +from test_support import cosmos_network_guard + +cosmos_network_guard.pytest_sessionstart(None) +module = importlib.import_module(sys.argv[1]) +result = module.test_bootstrap_container_sdk_contract() +if result is False: + raise AssertionError("The bootstrap SDK double contract failed") +container = module.FakeConfigCosmosContainer() +initial = container.read_item("app_settings", "app_settings") +saved = container.upsert_item({**initial, "settings": {"test": True}}) +rows = list(container.query_items("SELECT * FROM c", partition_key="app_settings")) +if not isinstance(initial, CosmosDict) or not isinstance(saved, CosmosDict): + raise AssertionError("Bootstrap point responses must use the installed SDK") +if initial["_etag"] == saved["_etag"] or not rows or type(rows[0]) is not dict: + raise AssertionError("Bootstrap ETags/query rows must retain the SDK-default contract") +cosmos_network_guard.pytest_sessionfinish(None, 0) +''' + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, "-c", script, module_name], cwd=root, + capture_output=True, text=True, timeout=120, + env={ + **os.environ, "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": os.pathsep.join((str(root / "application" / "single_app"), str(root / "functional_tests"))), + }, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/functional_tests/test_plugin_outbound_destination_security.py b/functional_tests/test_plugin_outbound_destination_security.py new file mode 100644 index 000000000..cfa522812 --- /dev/null +++ b/functional_tests/test_plugin_outbound_destination_security.py @@ -0,0 +1,427 @@ +# test_plugin_outbound_destination_security.py +"""Functional tests for action destination, authoring and credential boundaries. + +Version: 0.261.436 +Implemented in: 0.261.435 + +Real route/merge bodies and validators run with scoped storage seams. HTTP +connectors are synthetic; DNS and physical connection tests use blocked I/O. +""" + +import socket +import sys +import os +import subprocess +from contextlib import ExitStack +from copy import deepcopy +from importlib import import_module +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch +from urllib.parse import urlsplit + +import pytest +import requests +from flask import Blueprint + +import functions_outbound_http as transport +from test_support.agent_delegation import execute_functions +from test_workspace_authoring_backend import _load_module, action_payload, create, environment + + +@pytest.fixture +def routes(environment): + env = environment + probe = Mock(return_value={"success": True, "status": 200}) + http = Mock(return_value=SimpleNamespace(status_code=200, json=lambda: {"status": "ok"})) + ns = env.namespace + ns.update({ + "bpap": Blueprint("outbound_security", __name__), + "test_openapi_connection": probe, "guarded_probe_request": http, + }) + execute_functions("route_backend_plugins.py", { + "_run_action_connection_test", "test_openapi_action_connection", "test_rocksdb_connection", + }, ns) + env.app.register_blueprint(ns["bpap"]) + return SimpleNamespace(env=env, probe=probe, http=http) + + +@pytest.mark.parametrize("target", [ + "http://127.0.0.1", "http://169.254.169.254", "http://[::1]", +]) +def test_rocksdb_rejects_forbidden_targets_before_http(routes, target): + response = routes.env.client.post("/api/plugins/test-rocksdb-connection", json={ + "base_url": target, "auth_scheme": "none", + }) + assert response.status_code == 400 + assert response.get_json()["success"] is False + routes.http.assert_not_called() + assert routes.env.services.secret_reads == [] + + +@pytest.mark.parametrize("target", [ + "http://192.168.10.20:8090", "https://yamcs.corp.internal:8443", "https://api.public.example", +]) +def test_rocksdb_preserves_private_and_public_connection_tests(routes, target): + response = routes.env.client.post("/api/plugins/test-rocksdb-connection", json={ + "base_url": target, "auth_scheme": "none", + }) + assert response.status_code == 200, response.get_json() + assert response.get_json()["success"] is True + assert routes.http.call_args.args[:2] == ("GET", f"{target}/health") + + +@pytest.mark.parametrize("path,payload", [ + ("/api/plugins/test-rocksdb-connection", {"base_url": "https://service.corp.internal"}), + ("/api/plugins/test-openapi-connection", {"endpoint": "https://service.corp.internal"}), +]) +def test_disabled_personal_authoring_stops_tests_before_credentials_or_network(routes, path, payload): + routes.env.services.settings["allow_user_plugins"] = False + response = routes.env.client.post(path, json=payload) + assert response.status_code == 403 + routes.probe.assert_not_called() + routes.http.assert_not_called() + assert routes.env.services.secret_reads == [] + + +def test_denied_action_type_cannot_use_the_test_endpoint(routes): + routes.env.services.denied_types.add("rocksdb") + response = routes.env.client.post("/api/plugins/test-rocksdb-connection", json={ + "base_url": "https://service.corp.internal", + }) + assert response.status_code == 403 + routes.http.assert_not_called() + + +def test_dns_alias_to_metadata_is_rejected_by_real_http_transport(routes, monkeypatch): + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(socket, "getaddrinfo", lambda *args, **kwargs: [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("169.254.169.254", 80)), + ]) + connect = Mock() + monkeypatch.setattr(transport, "create_connection", connect) + routes.env.namespace["guarded_probe_request"] = transport.guarded_probe_request + response = routes.env.client.post("/api/plugins/test-rocksdb-connection", json={ + "base_url": "http://service.corp.internal", "auth_scheme": "none", + }) + assert response.status_code == 400, response.get_json() + connect.assert_not_called() + + +@pytest.mark.parametrize("replacement,expected_status", [ + ("***REDACTED***", 400), ("", 400), ("fresh-test-credential", 200), +]) +def test_changed_test_origin_cannot_inherit_an_opaque_saved_key(routes, replacement, expected_status): + env = routes.env + stored = env.services.add("actions", "personal", "actor", { + **action_payload(), "id": "owned-action", "endpoint": "https://original.corp.internal", + }) + before = deepcopy(env.services.records) + response = env.client.post("/api/plugins/test-openapi-connection", json={ + "plugin_context": {"scope": "personal", "id": stored["id"]}, + "endpoint": "https://changed.corp.internal", + "auth": {"type": "key", "key": replacement}, "additionalFields": {}, + }) + assert response.status_code == expected_status, response.get_json() + if expected_status == 400: + routes.probe.assert_not_called() + else: + assert routes.probe.call_args.args[0]["auth"]["key"] == replacement + assert env.services.records == before + assert env.services.writes == [] + + +def test_same_origin_masked_connection_test_still_works(routes): + env = routes.env + stored = env.services.add("actions", "personal", "actor", { + **action_payload(), "id": "owned-action", "endpoint": "https://service.corp.internal", + }) + response = env.client.post("/api/plugins/test-openapi-connection", json={ + "plugin_context": {"scope": "personal", "id": stored["id"]}, + "endpoint": "https://service.corp.internal", "auth": {"type": "key", "key": "***REDACTED***"}, + }) + assert response.status_code == 200, response.get_json() + assert routes.probe.call_args.args[0]["auth"]["key"] == stored["auth"]["key"] + + +@pytest.mark.parametrize("saved_context", [False, True]) +def test_test_requests_cannot_dereference_arbitrary_same_scope_vault_names(routes, saved_context): + env = routes.env + env.services.settings["enable_key_vault_secret_storage"] = True + reference = env.keyvault.build_full_secret_name("other-action-key", "actor", "action", "user") + env.services.vault[reference] = "synthetic-other-action-key" + payload = { + "endpoint": "https://service.corp.internal", + "auth": {"type": "key", "key": reference}, "additionalFields": {}, + } + if saved_context: + stored = env.services.add("actions", "personal", "actor", { + **action_payload(), "id": "owned-action", "endpoint": payload["endpoint"], + }) + payload["plugin_context"] = {"scope": "personal", "id": stored["id"]} + response = env.client.post("/api/plugins/test-openapi-connection", json=payload) + assert response.status_code == 400 + assert env.services.secret_reads == [] + routes.probe.assert_not_called() + + +def test_matching_saved_vault_field_can_still_be_tested(routes): + env = routes.env + env.services.settings["enable_key_vault_secret_storage"] = True + reference = env.keyvault.build_full_secret_name("owned-action-key", "actor", "action", "user") + env.services.vault[reference] = "synthetic-owned-key" + stored = env.services.add("actions", "personal", "actor", { + **action_payload(), "id": "owned-action", "endpoint": "https://service.corp.internal", + "auth": {"type": "key", "key": reference}, + }) + response = env.client.post("/api/plugins/test-openapi-connection", json={ + "endpoint": stored["endpoint"], "plugin_context": {"scope": "personal", "id": stored["id"]}, + "auth": {"type": "key", "key": "***REDACTED***"}, + }) + assert response.status_code == 200, response.get_json() + assert routes.probe.call_args.args[0]["auth"]["key"] == "synthetic-owned-key" + + +@pytest.mark.parametrize("replacement,expected_status", [ + (None, 400), ("***REDACTED***", 400), ("fresh-test-credential", 200), +]) +def test_save_then_runtime_cannot_bypass_destination_binding(environment, replacement, expected_status): + env = environment + resource = create(env, "plugins", action_payload()) + identifier = resource["record"]["id"] + before = deepcopy(env.services.records) + updates = {"endpoint": "https://changed.corp.internal"} + if replacement is not None: + updates["auth"] = {"key": replacement} + response = env.client.patch(f"/api/user/plugins/{identifier}?view=editor", json={ + "updates": updates, "expected_revision": resource["revision"], + }) + assert response.status_code == expected_status, response.get_json() + if expected_status == 400: + assert env.services.records == before + else: + assert response.get_json()["record"]["auth"]["key"] == "***REDACTED***" + + +def test_connector_mismatch_is_rejected_before_hydration(routes): + env = routes.env + stored = env.services.add("actions", "personal", "actor", {**action_payload(), "id": "openapi-action"}) + response = env.client.post("/api/plugins/test-rocksdb-connection", json={ + "base_url": "https://service.corp.internal", + "existing_plugin": {"scope": "personal", "id": stored["id"]}, + }) + assert response.status_code == 403 + routes.http.assert_not_called() + assert env.services.secret_reads == [] + + +def test_legacy_databricks_type_keeps_its_connection_test_permission(environment): + env = environment + stored = env.services.add("actions", "personal", "actor", { + **action_payload("databricks_table"), "id": "legacy-databricks", + }) + loaded = env.namespace["_load_existing_plugin_for_test"]( + {"scope": "personal", "id": stored["id"]}, "actor", + ) + with env.app.test_request_context(): + scope = env.namespace["_authorize_action_test_scope"]({}, loaded, "actor", "databricks") + assert scope == ("personal", "actor") + assert env.services.secret_reads == [] + + +@pytest.mark.parametrize("auth_method", ["none", "api_key"]) +def test_native_yamcs_sdk_keeps_reverse_proxy_authentication_and_tls(environment, monkeypatch, auth_method): + captured = [] + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + monkeypatch.delenv(name, raising=False) + + def send(adapter, request, **kwargs): + captured.append((request, kwargs)) + response = requests.Response() + response.status_code = 200 + response.url, response.request = request.url, request + response._content = b"" + return response + + monkeypatch.setattr(requests.adapters.HTTPAdapter, "send", send) + with patch.dict(sys.modules), ExitStack() as stack: + # Older standalone tests install fake SDK modules; restore the installed SDK here. + for name in list(sys.modules): + if name == "yamcs" or name.startswith("yamcs."): + sys.modules.pop(name) + sdk = import_module("yamcs.client") + module = _load_module(stack, "semantic_kernel_plugins.yamcs_plugin", r"semantic_kernel_plugins\yamcs_plugin.py") + plugin = module.YamcsPlugin({ + "name": "proxy", "type": "yamcs", "endpoint": "https://yamcs.corp.internal:8090", + "auth": {"type": "NoAuth"} if auth_method == "none" else {"type": "key", "key": "synthetic-api-key"}, + "additionalFields": { + "instance": "simulator", "auth_method": auth_method, "enable_basic_auth": True, + "basic_auth_username": "operator", "basic_auth_password": "synthetic-proxy-password", + "tls_verify": False, "timeout": 7, + }, + }) + client = plugin._connect() + try: + assert isinstance(client, sdk.YamcsClient) + client.get_server_info() + request, options = captured[-1] + target = urlsplit(request.url) + assert (target.scheme, target.netloc) == ("https", "yamcs.corp.internal:8090") + assert request.headers["Authorization"] == module.build_yamcs_basic_auth_header( + "operator", "synthetic-proxy-password", + ) + if auth_method == "api_key": + assert request.headers["x-api-key"] == "synthetic-api-key" + assert options["verify"] is False + assert options["timeout"] == 7 + assert isinstance(client.ctx.session.get_adapter("https://"), transport.GuardedHTTPAdapter) + finally: + client.close() + + +def test_native_tableau_installs_guard_before_server_version_discovery(environment, monkeypatch): + captured = [] + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + monkeypatch.delenv(name, raising=False) + + def send(adapter, request, **kwargs): + captured.append((adapter, request, kwargs)) + response = requests.Response() + response.status_code = 200 + response.url, response.request = request.url, request + response._content = ( + b'' + b'2025.1' + b'3.25' + ) + return response + + monkeypatch.setattr(requests.adapters.HTTPAdapter, "send", send) + with patch.dict(sys.modules), ExitStack() as stack: + for name in list(sys.modules): + if name == "tableauserverclient" or name.startswith("tableauserverclient."): + sys.modules.pop(name) + module = _load_module(stack, "semantic_kernel_plugins.tableau_plugin", r"semantic_kernel_plugins\tableau_plugin.py") + plugin = module.TableauPlugin({ + "name": "tableau", "type": "tableau", "endpoint": "https://tableau.corp.internal", + "auth": {"type": "key", "identity": "operator", "key": "synthetic-pat"}, + "additionalFields": {"use_server_version": True, "timeout": 9}, + }) + server = plugin._create_server() + try: + adapter, request, options = captured[0] + assert isinstance(adapter, transport.GuardedHTTPAdapter) + target = urlsplit(request.url) + assert (target.scheme, target.netloc) == ("https", "tableau.corp.internal") + assert options["timeout"] == 9 + assert server.version == "3.25" + finally: + server._session.close() + + +def test_native_mcp_connectors_receive_guarded_transports(): + # Native Pydantic/SK generic caches need a fresh process, not sys.modules reloads. + script = r''' +import sys +import types +from unittest.mock import Mock +from flask import Flask, session +from test_support import cosmos_network_guard + +cosmos_network_guard.pytest_sessionstart(None) +for name, values in { + "functions_appinsights": {"log_event": Mock()}, + "functions_debug": {"debug_print": Mock()}, + "functions_mcp_preconfigurations": {"authorize_mcp_action": Mock()}, + "semantic_kernel_plugins.mcp_plugin": {"McpPlugin": Mock()}, +}.items(): + module = types.ModuleType(name) + module.__dict__.update(values) + sys.modules[name] = module + +from functions_action_manifest import McpActionOrigin +from functions_outbound_http import create_guarded_httpx_client +from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStreamableHttpPlugin +from semantic_kernel_plugins.mcp_guarded_transport import GuardedMcpWebsocketPlugin +from semantic_kernel_plugins.mcp_plugin_factory import McpPluginFactory + +McpPluginFactory._get_current_settings = staticmethod(lambda: {}) +app = Flask("native_mcp_transport") +app.secret_key = "synthetic-only" +with app.test_request_context(): + session["user"] = {"oid": "actor"} + for protocol, expected in ( + ("sse", MCPSsePlugin), ("streamable_http", MCPStreamableHttpPlugin), + ("websocket", GuardedMcpWebsocketPlugin), + ): + connector = McpPluginFactory.create_connector({ + "name": "mcp", "type": "mcp", + "endpoint": "wss://mcp.corp.internal" if protocol == "websocket" else "https://mcp.corp.internal", + "auth": {"type": "NoAuth"}, + "additionalFields": {"transport": protocol, "auth_method": "none", "connect_timeout": 12}, + }, origin=McpActionOrigin("personal", "actor")) + if not isinstance(connector, expected): + raise AssertionError("The native MCP connector type changed") + if protocol == "websocket": + if connector.connect_timeout != 12: + raise AssertionError("The guarded WebSocket timeout was not retained") + elif connector._client_kwargs.get("httpx_client_factory") is not create_guarded_httpx_client: + raise AssertionError("The native MCP HTTP client factory was not guarded") + +import anyio +import json +from contextlib import asynccontextmanager +from mcp.shared.message import SessionMessage +from mcp.types import JSONRPCMessage +from semantic_kernel_plugins import mcp_guarded_transport as bridge + +async def check_wire_contract(): + sent = [] + completed = anyio.Event() + closed = [] + + class WireConnection: + def __aiter__(self): + async def incoming(): + yield '{"jsonrpc":"2.0","method":"notifications/test"}' + await anyio.sleep_forever() + return incoming() + + async def send(self, value): + sent.append(json.loads(value)) + completed.set() + + @asynccontextmanager + async def open_wire(url, timeout): + try: + yield WireConnection() + finally: + closed.append(True) + + bridge.guarded_websocket_connection = open_wire + async with bridge.guarded_mcp_websocket_client("wss://mcp.corp.internal", 12) as (incoming, outgoing): + received = await incoming.receive() + if not isinstance(received, SessionMessage) or received.message.root.method != "notifications/test": + raise AssertionError("The guarded WebSocket lost the native receive contract") + await outgoing.send(SessionMessage(JSONRPCMessage.model_validate( + {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}, + ))) + await completed.wait() + if sent != [{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}] or closed != [True]: + raise AssertionError("The guarded WebSocket lost its native send or cleanup contract") + +anyio.run(check_wire_contract) +if "config" in sys.modules: + raise AssertionError("Connector construction must not bootstrap Azure storage") +cosmos_network_guard.pytest_sessionfinish(None, 0) +''' + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, "-c", script], cwd=root, capture_output=True, text=True, timeout=120, + env={ + **os.environ, "PYTHONDONTWRITEBYTECODE": "1", + "PYTHONPATH": os.pathsep.join((str(root / "application" / "single_app"), str(root / "functional_tests"))), + }, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/functional_tests/test_plugin_routes_cosmos_contract.py b/functional_tests/test_plugin_routes_cosmos_contract.py index 9eb316b7f..d7f2e073f 100644 --- a/functional_tests/test_plugin_routes_cosmos_contract.py +++ b/functional_tests/test_plugin_routes_cosmos_contract.py @@ -1,10 +1,10 @@ # test_plugin_routes_cosmos_contract.py """Runtime evidence for the plugin route owners' Cosmos response contracts. -Version: 0.261.353 +Version: 0.261.435 Implemented in: 0.261.352 -Execute unchanged route and legacy-validator definitions with the installed SDK's +Execute actual route and legacy-validator definitions with the installed SDK's CosmosDict point responses and offline external services. This covers the owners whose module fingerprints changed with PR #1787's SQL diagnostics. """ @@ -24,6 +24,7 @@ import pytest from functions_action_manifest import McpConfigurationError, is_retired_mcp_stdio, resolve_action_type +from functions_workspace_authoring import validate_action_connection_update from test_support.agent_delegation import APP_ROOT, execute_functions from test_support.m365 import CosmosContainer from test_support.offline_bootstrap import OfflineCosmos @@ -54,6 +55,8 @@ def plugin_routes(monkeypatch): "McpConfigurationError": McpConfigurationError, "is_retired_mcp_stdio": is_retired_mcp_stdio, "resolve_action_type": resolve_action_type, + "validate_action_connection_update": validate_action_connection_update, + "_authorize_action_test_scope": Mock(return_value=("personal", "actor")), } # Load the real validator and its constants without unrelated app bootstrap. tree = ast.parse((APP_ROOT / "json_schema_validation.py").read_text(encoding="utf-8")) diff --git a/functional_tests/test_tableau_action_plugin.py b/functional_tests/test_tableau_action_plugin.py index e57d278de..dd9fd75b9 100644 --- a/functional_tests/test_tableau_action_plugin.py +++ b/functional_tests/test_tableau_action_plugin.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for Tableau action plugin configuration. -Version: 0.250.047 +Version: 0.261.435 Implemented in: 0.241.210 This test ensures the Tableau action factory, plugin, manifest validation, @@ -152,10 +152,11 @@ def __exit__(self, _exc_type, _exc, _traceback): class FakeServer: last_instance = None - def __init__(self, endpoint, use_server_version=True): + def __init__(self, endpoint, use_server_version=True, session_factory=None, http_options=None): self.endpoint = endpoint + self.session = session_factory() if session_factory else None self.use_server_version = use_server_version - self.http_options = {} + self.http_options = dict(http_options or {}) self.auth = FakeAuthManager() self.projects = FakeCollection([ FakeTableauItem(id="project-1", name="Finance", description="Finance analytics"), diff --git a/functional_tests/test_workspace_authoring_backend.py b/functional_tests/test_workspace_authoring_backend.py index 7027d9700..b77ab629f 100644 --- a/functional_tests/test_workspace_authoring_backend.py +++ b/functional_tests/test_workspace_authoring_backend.py @@ -1,7 +1,7 @@ # test_workspace_authoring_backend.py """Executable contracts for native personal agent/action authoring. -Version: 0.261.432 +Version: 0.261.435 Implemented in: 0.261.096 Real Flask route definitions, validators, payload normalizers, Key Vault helpers, @@ -26,6 +26,8 @@ from azure.core import exceptions as azure_core_exceptions import pytest from flask import Blueprint, Flask, jsonify, request, session +from functions_outbound_http import guard_login_credentials, guard_requests_session +from functions_outbound_destinations import OutboundDestinationError from test_support.agent_delegation import ( APP_ROOT, @@ -204,6 +206,7 @@ def update_secret_properties(self, name, expires_on=None): services.modules["config"].SECRET_KEY = "test-only-not-a-production-credential" services.appinsights.sanitize_log_message = lambda message: message services.appinsights.debug_print = Mock() + services.appinsights.is_debug_enabled = lambda: False for prefix in ("agent", "action"): for operation in ("creation", "update", "deletion"): setattr(services.activity, f"log_{prefix}_{operation}", Mock()) @@ -305,6 +308,10 @@ def update_secret_properties(self, name, expires_on=None): "clear_editor_test_secrets": authoring.clear_editor_test_secrets, "WorkspaceAuthoringValidation": authoring.WorkspaceAuthoringValidation, "validate_editor_action_manifest": authoring.validate_editor_action_manifest, + "validate_action_connection_update": authoring.validate_action_connection_update, + "guard_login_credentials": guard_login_credentials, + "guard_requests_session": guard_requests_session, + "OutboundDestinationError": OutboundDestinationError, "sanitize_agent_payload": payload.sanitize_agent_payload, "is_azure_ai_foundry_agent": payload.is_azure_ai_foundry_agent, "AgentPayloadError": payload.AgentPayloadError, @@ -378,6 +385,7 @@ def update_secret_properties(self, name, expires_on=None): "_resolve_action_identity_context", "_resolve_plugin_secret_context", "_action_test_origin", "_action_origin_secret_context", "_handle_mcp_configuration_error", "_prepare_action_test_manifest", + "_authorize_action_test_scope", "_resolve_group_for_test", "_validate_flat_http_action_test", "_flatten_editor_test_fields", "_prepare_editor_sql_test_data", "_prepare_editor_yamcs_test_data", "_is_personal_editor_test_request", "_assert_personal_editor_test_access", "_hydrate_sql_test_identity", "_load_existing_plugin_for_sql_test", "_resolve_secret_value_for_sql_test", @@ -1489,6 +1497,7 @@ def test_sql_editor_driver_failures_do_not_echo_resolved_credentials(environment def test_new_yamcs_identity_testing_does_not_expand_group_or_admin_behavior(environment): env = environment + env.namespace["require_active_group"] = Mock(side_effect=PermissionError("No group editing grant")) for scope in ("group", "global"): response = env.client.post("/api/plugins/test-yamcs-connection", json={ "identity_id": "another-scope-identity", @@ -1496,7 +1505,7 @@ def test_new_yamcs_identity_testing_does_not_expand_group_or_admin_behavior(envi "additionalFields": {"instance": "demo"}, "server_url": "https://yamcs.example.test", "instance": "demo", "action_scope": scope, }) - assert response.status_code == 400 + assert response.status_code == 403 env.identities.hydrate_action_identity_reference.assert_not_called() diff --git a/functional_tests/test_yamcs_action_plugin.py b/functional_tests/test_yamcs_action_plugin.py index 338c9563c..bef252dff 100644 --- a/functional_tests/test_yamcs_action_plugin.py +++ b/functional_tests/test_yamcs_action_plugin.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for the Yamcs mission control action plugin. -Version: 0.250.212 +Version: 0.261.436 Implemented in: 0.250.212 This test ensures the Yamcs action operations helpers, factory, plugin metadata, @@ -15,6 +15,12 @@ import types from pathlib import Path +import requests +from azure.core import MatchConditions +from azure.cosmos import exceptions as cosmos_exceptions +from azure.cosmos._cosmos_responses import CosmosDict + +from test_support.m365 import CosmosContainer from test_support.versioning import assert_app_version_at_least @@ -38,25 +44,21 @@ def decorator(function): sys.modules.setdefault("semantic_kernel_plugins.plugin_invocation_logger", plugin_invocation_logger_stub) -class FakeConfigCosmosContainer: - """Minimal Cosmos container stand-in for importing app config in tests.""" +class FakeConfigCosmosContainer(CosmosContainer): + """SDK-default container with a deterministic bootstrap settings document.""" def __init__(self): - self.items = {} + super().__init__(partition_field="id") def read_item(self, item, partition_key=None): - if item in self.items: - return self.items[item] - if item == "app_settings": - return {"id": "app_settings", "settings": {}} - raise KeyError(item) - - def upsert_item(self, item): - self.items[item["id"]] = item - return item - - def query_items(self, *args, **kwargs): - return [] + partition_key = item if partition_key is None else partition_key + with self.lock: + try: + return super().read_item(item, partition_key) + except cosmos_exceptions.CosmosResourceNotFoundError: + if item == "app_settings" and partition_key == item: + return self.create_item({"id": item, "settings": {}}) + raise class FakeConfigCosmosDatabase: @@ -112,7 +114,7 @@ def __init__(self, **fields): self.__dict__.update(fields) -class FakeSession: +class FakeSession(requests.Session): def request(self, *args, **kwargs): return None @@ -261,10 +263,10 @@ class FakeYamcsClient: def __init__(self, address, credentials=None, tls_verify=True, user_agent=None, **kwargs): self.address = address - self.credentials = credentials self.tls_verify = tls_verify self.user_agent = user_agent self.ctx = FakeContext() + self.credentials = credentials.login(self.ctx.session, None, None) if credentials else None self.closed = False FakeYamcsClient.last_instance = self @@ -307,12 +309,15 @@ def __init__(self, username=None, password=None, access_token=None, **kwargs): self.password = password self.access_token = access_token + def login(self, session, auth_root, on_token_update): + return self + -class FakeAPIKeyCredentials: +class FakeAPIKeyCredentials(FakeCredentials): """Mirrors yamcs.client.APIKeyCredentials, which stores the key on `password`.""" def __init__(self, key): - self.password = key + super().__init__(password=key) def install_fake_yamcs_client(): @@ -800,6 +805,25 @@ def test_missing_client_library_is_reported(): return True +def test_bootstrap_container_sdk_contract(): + container = FakeConfigCosmosContainer() + initial = container.read_item("app_settings", "app_settings") + saved = container.upsert_item({**initial, "settings": {"test": True}}) + assert isinstance(initial, CosmosDict) and isinstance(saved, CosmosDict) + assert initial["_etag"] != saved["_etag"] + try: + container.replace_item("app_settings", initial, etag=initial["_etag"], match_condition=MatchConditions.IfNotModified) + except cosmos_exceptions.CosmosHttpResponseError as exc: + assert exc.status_code == 412 + else: + raise AssertionError("A stale conditional write must fail") + current = container.read_item("app_settings", "app_settings") + rows = list(container.query_items("SELECT * FROM c", partition_key="app_settings")) + assert current["settings"] == {"test": True} + assert type(rows[0]) is dict + return True + + def test_app_version(): """The Yamcs action ships in at least its implementation version.""" print("Testing SimpleChat version floor...") @@ -823,6 +847,7 @@ def run_all_tests(): test_archive_sql_read_only_guard, test_result_truncation_and_error_redaction, test_missing_client_library_is_reported, + test_bootstrap_container_sdk_contract, test_app_version, ] diff --git a/functional_tests/test_yamcs_basic_auth.py b/functional_tests/test_yamcs_basic_auth.py index 349fa4f90..8027cf4ae 100644 --- a/functional_tests/test_yamcs_basic_auth.py +++ b/functional_tests/test_yamcs_basic_auth.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for Yamcs reverse-proxy HTTP Basic authentication. -Version: 0.261.012 +Version: 0.261.436 Implemented in: 0.261.012 This test ensures a Yamcs action can authenticate against a reverse proxy (such as the @@ -19,6 +19,12 @@ import types from pathlib import Path +import requests +from azure.core import MatchConditions +from azure.cosmos import exceptions as cosmos_exceptions +from azure.cosmos._cosmos_responses import CosmosDict + +from test_support.m365 import CosmosContainer from test_support.versioning import assert_app_version_at_least @@ -42,25 +48,21 @@ def decorator(function): sys.modules.setdefault("semantic_kernel_plugins.plugin_invocation_logger", plugin_invocation_logger_stub) -class FakeConfigCosmosContainer: - """Minimal Cosmos container stand-in for importing app config in tests.""" +class FakeConfigCosmosContainer(CosmosContainer): + """SDK-default container with a deterministic bootstrap settings document.""" def __init__(self): - self.items = {} + super().__init__(partition_field="id") def read_item(self, item, partition_key=None): - if item in self.items: - return self.items[item] - if item == "app_settings": - return {"id": "app_settings", "settings": {}} - raise KeyError(item) - - def upsert_item(self, item): - self.items[item["id"]] = item - return item - - def query_items(self, *args, **kwargs): - return [] + partition_key = item if partition_key is None else partition_key + with self.lock: + try: + return super().read_item(item, partition_key) + except cosmos_exceptions.CosmosResourceNotFoundError: + if item == "app_settings" and partition_key == item: + return self.create_item({"id": item, "settings": {}}) + raise class FakeConfigCosmosDatabase: @@ -114,11 +116,11 @@ def create_database_if_not_exists(self, *args, **kwargs): azure_cosmos.CosmosClient = original_cosmos_client -class FakeSession: +class FakeSession(requests.Session): """requests.Session stand-in that records the headers the plugin applies.""" def __init__(self): - self.headers = {} + super().__init__() def request(self, *args, **kwargs): return None @@ -132,10 +134,10 @@ def __init__(self): class FakeYamcsClient: def __init__(self, address, credentials=None, tls_verify=True, user_agent=None, **kwargs): self.address = address - self.credentials = credentials self.tls_verify = tls_verify self.user_agent = user_agent self.ctx = FakeContext() + self.credentials = credentials.login(self.ctx.session, None, None) if credentials else None self.closed = False def close(self): @@ -148,20 +150,22 @@ def __init__(self, username=None, password=None, access_token=None, **kwargs): self.password = password self.access_token = access_token + def login(self, session, auth_root, on_token_update): + return self -class FakeAPIKeyCredentials: + +class FakeAPIKeyCredentials(FakeCredentials): """Mirrors yamcs.client.APIKeyCredentials, which stores the key on `password`.""" def __init__(self, key): - self.password = key + super().__init__(password=key) -class FakeBasicAuthCredentials: +class FakeBasicAuthCredentials(FakeCredentials): """Mirrors yamcs.client.BasicAuthCredentials, which sends an Authorization header.""" def __init__(self, username, password): - self.username = username - self.password = password + super().__init__(username=username, password=password) def install_fake_yamcs_client(include_basic_auth=True): @@ -514,6 +518,25 @@ def test_modal_payload_preserves_stored_credential_when_disabled(): return True +def test_bootstrap_container_sdk_contract(): + container = FakeConfigCosmosContainer() + initial = container.read_item("app_settings", "app_settings") + saved = container.upsert_item({**initial, "settings": {"test": True}}) + assert isinstance(initial, CosmosDict) and isinstance(saved, CosmosDict) + assert initial["_etag"] != saved["_etag"] + try: + container.replace_item("app_settings", initial, etag=initial["_etag"], match_condition=MatchConditions.IfNotModified) + except cosmos_exceptions.CosmosHttpResponseError as exc: + assert exc.status_code == 412 + else: + raise AssertionError("A stale conditional write must fail") + current = container.read_item("app_settings", "app_settings") + rows = list(container.query_items("SELECT * FROM c", partition_key="app_settings")) + assert current["settings"] == {"test": True} + assert type(rows[0]) is dict + return True + + def test_app_version(): """The application version is at least the release that added basic auth support.""" print("Testing SimpleChat version floor...") @@ -535,6 +558,7 @@ def test_app_version(): test_basic_auth_password_is_treated_as_a_secret, test_proxy_identity_reference_contract, test_modal_payload_preserves_stored_credential_when_disabled, + test_bootstrap_container_sdk_contract, test_app_version, ] diff --git a/scripts/build_docs_inventory.py b/scripts/build_docs_inventory.py index 01f4b13cb..12bf6e1d1 100644 --- a/scripts/build_docs_inventory.py +++ b/scripts/build_docs_inventory.py @@ -34,6 +34,7 @@ "__init__.py", "base_plugin.py", "logged_plugin_loader.py", + "mcp_guarded_transport.py", # Native transport infrastructure, not an authorable action. "plugin_health_checker.py", "plugin_invocation_logger.py", "plugin_invocation_thoughts.py",