diff --git a/.github/actions/orchestrator-free-sidecar/action.yml b/.github/actions/orchestrator-free-sidecar/action.yml index edddfe1bc3..7f07abfc84 100644 --- a/.github/actions/orchestrator-free-sidecar/action.yml +++ b/.github/actions/orchestrator-free-sidecar/action.yml @@ -1,6 +1,19 @@ name: Orchestrator free sidecar description: Provision the immutable contextual-orchestrator orchestrator/free gateway for a model-backed workflow. inputs: + gateway_mode: + description: Bootstrap mode; external remains blocked until a released CO adapter is registered. + required: false + default: "sidecar" + gateway_base_url: + description: Deployment-authorized HTTPS gateway origin; never sourced from PR content. + required: false + gateway_token_file: + description: Private runner-owned inference token file; never a raw credential input. + required: false + gateway_contract_revision: + description: Immutable released CO adapter revision registered by protected owner source. + required: false require_zdr: description: Require an attested Zero Data Retention route for private or internal content. required: false @@ -26,6 +39,10 @@ runs: - name: Provision contextual-orchestrator orchestrator/free shell: bash --noprofile --norc -e -o pipefail {0} env: + CONTEXTUAL_ORCHESTRATOR_GATEWAY_MODE: ${{ inputs.gateway_mode }} + CONTEXTUAL_ORCHESTRATOR_BASE_URL: ${{ inputs.gateway_base_url }} + CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE: ${{ inputs.gateway_token_file }} + CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION: ${{ inputs.gateway_contract_revision }} CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ inputs.require_zdr }} ORCHESTRATOR_CATALOG_LIMIT: ${{ inputs.catalog_limit }} ORCHESTRATOR_CATALOG_ACCOUNT_CAP: ${{ inputs.catalog_account_cap }} diff --git a/docs/doctoring/external-review-gateway-admission.md b/docs/doctoring/external-review-gateway-admission.md new file mode 100644 index 0000000000..1ffb7cf96e --- /dev/null +++ b/docs/doctoring/external-review-gateway-admission.md @@ -0,0 +1,89 @@ +# External review gateway admission + +Status: proposed, disabled by default, with no registered released adapter. +Baseline: protected `.github` main `dd0b96feded94f66ecf59b25a5a9b58cfc8b4f69`. + +## Problem and scope + +The existing review bootstrap always starts a loopback CO instance using provider +credentials. An inference-token-only external deployment cannot use that path. +The CO inference APIs already expose authenticated model discovery and chat; +administrator `/readyz` access is neither necessary nor appropriate. + +CO PR #1084 proposes the consolidated request/evidence contract but is not a +released dependency. Its fixture, source and proposed branch are not imported +here. No production workflow opts into this change, and no provider request +or production credential is used in its tests. + +## Chosen boundary + +The existing composite action adds `gateway_mode`, default `sidecar`. Explicit +`external` branches before provider-secret bootstrap and admits only a protected +source-registered immutable contract adapter. The registry is empty, so every +real external invocation currently fails with `released_contract_unavailable` +before token access, network calls, checkout of CO, or readiness exports. +An environment value containing a full commit hash cannot authorize adoption. + +The owner probe port describes inference `/v1/models` discovery and capability +checks through `/v1/chat/completions`. It requires the exact `orchestrator/free` +alias plus JSON object/schema and tool-call evidence; a failed or missing +capability prevents partial readiness. The port accepts only an explicit HTTPS +origin and an absolute path to an owned, mode-0600, regular token file. It never +resolves a symlink to repair an input or exports a raw bearer. +Successful test-double observations produce only bounded capability evidence. + +Discovery and each capability return an owner `ProbeReceipt`, not a boolean or +raw inventory. A discovery success attests that the adapter validated the exact +free alias; an absent free pool is `policy_unavailable`. The closed failure +categories are `authentication_failed`, `transport_failed`, `invalid_response`, +`policy_unavailable` and `capability_unavailable`. Evidence contains only the +fixed probe name, an integer HTTP status (or null), pass/fail result and category. +The caller validates every receipt before use; success requires status 200 plus +the adapter's semantic validation. Invalid fields and unexpected exceptions +become `invalid_response` at the active probe, with no raw error text. + +Main preserves the bounded failed stage/category/status in its error annotation +and never publishes partial readiness. Successful evidence records only +`requested_model=orchestrator/free`, not an upstream model identifier. This port +vocabulary follows the proposed CO evidence semantics; it is not a released +transport implementation or authorization to register an adapter. + +TLS verification, redirect rejection, trusted origin authorization, secure token +opening and full response validation are obligations of the future released +adapter. They are not implemented HTTP transport in this delta. No claim of a +working external gateway or verified TLS follows from these port tests. + +## Alternatives and next owner work + +- An early return that exports a supplied URL/token path would silently bypass + all capability and private-data policy checks; rejected. +- Copying CO #1084's fixture or provider discovery into `.github` would create an + unreleased dependency or duplicate owner logic; rejected. +- A new gateway service is unnecessary: use CO's existing inference contract. + +After CO publishes its reviewed immutable contract, a separate protected change +must implement/register the released adapter and verify live external HTTPS, +token-only authentication, exact free-pool capability, and sanitized failure +evidence. It must retain failure classification without paid fallback and must +not impose a model-duration timeout. No code may infer provider retention from +a successful response: ZDR evidence is configured policy only. + +Every private review request must retain `zdr_only`, not just preflight. This +bootstrap exports that requirement for the future caller integration; it cannot +enforce it on an unrelated client's later HTTP requests. Strix's fixed-loopback +allowlist and Noema's private-origin exception need separate reviewed integration +before deployment. Default sidecar provisioning and those gates are unchanged. + +## Verification + +The baseline external-mode test failed because the legacy path demanded provider +secrets, and the port did not exist. Tests now exercise private-file/origin +admission, missing free inventory, each failed capability, unregistered revision, +safe output, and exceptions using owner test doubles. Synthetic data is confined +to unit tests. Full lifecycle evidence still requires protected review, release, +caller adoption, and a live exact-head review. + +The typed-receipt regression was RED on the boolean port (33 failures). Tests +cover every failure category at all four probes, malformed receipt fields, +legacy boolean/raw response results and sanitized main output. The registry +remains empty; only in-memory test doubles exercise receipt publication. diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 38d9551a32..4a8bdc58eb 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -49,6 +49,14 @@ log() { printf '[contextual-orchestrator-sidecar] %s\n' "$*"; } fail() { log "error: $*" >&2; exit 1; } +case "${CONTEXTUAL_ORCHESTRATOR_GATEWAY_MODE:-sidecar}" in + sidecar) ;; + external) + exec "$sidecar_python" "$ORG_REPO_ROOT/scripts/ci/external_review_gateway.py" + ;; + *) fail "unsupported gateway mode" ;; +esac + # Require at least one of the five provider secrets so we never boot an empty # (or mock) pool. Missing individual secrets are allowed — discovery skips the # unregistered provider — matching the review gateway contract. diff --git a/scripts/ci/external_review_gateway.py b/scripts/ci/external_review_gateway.py new file mode 100644 index 0000000000..af163f41cf --- /dev/null +++ b/scripts/ci/external_review_gateway.py @@ -0,0 +1,293 @@ +"""Fail-closed external review admission awaiting a released CO adapter. + +This owner port does not implement or copy the proposed CO request contract. +Only a protected source change may register a reviewed immutable adapter. +""" + +from __future__ import annotations + +import json +import os +import stat +import tempfile +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Protocol +from urllib.parse import urlsplit + + +class ProbeErrorCategory(str, Enum): + """Closed failure vocabulary accepted from a future released adapter.""" + + AUTHENTICATION_FAILED = "authentication_failed" + TRANSPORT_FAILED = "transport_failed" + INVALID_RESPONSE = "invalid_response" + POLICY_UNAVAILABLE = "policy_unavailable" + CAPABILITY_UNAVAILABLE = "capability_unavailable" + + +PROBE_NAMES = ("discovery", "json_object", "json_schema", "tool_call") + + +class GatewayAdmissionError(RuntimeError): + """Carry only validated, bounded admission evidence, never exception text.""" + + def __init__( + self, + error_category: str, + probe_name: str = "bootstrap", + http_status: int | None = None, + ): + """Project error details onto the closed, secret-free evidence fields.""" + allowed_categories = {item.value for item in ProbeErrorCategory} | { + "invalid_gateway_configuration", + "invalid_output_location", + "external_gateway_admission_failed", + } + safe_category = ( + error_category + if type(error_category) is str and error_category in allowed_categories + else "invalid_response" + ) + safe_probe = ( + probe_name + if type(probe_name) is str and probe_name in (*PROBE_NAMES, "bootstrap") + else "bootstrap" + ) + safe_status = ( + http_status + if type(http_status) is int and 100 <= http_status <= 599 + else None + ) + self.evidence = { + "probe_name": safe_probe, + "http_status": safe_status, + "result": "failed", + "error_category": safe_category, + } + super().__init__(safe_category) + + +@dataclass(frozen=True) +class ProbeReceipt: + """Semantic result; discovery success includes the exact free-model alias.""" + + probe_name: str + http_status: int | None + error_category: ProbeErrorCategory | None = None + + def safe_evidence(self, expected_probe: str) -> dict: + """Reject malformed adapter evidence before it reaches logs or gates.""" + if ( + type(self.probe_name) is not str + or self.probe_name != expected_probe + or ( + self.http_status is not None + and ( + type(self.http_status) is not int + or not 100 <= self.http_status <= 599 + ) + ) + or ( + self.error_category is not None + and type(self.error_category) is not ProbeErrorCategory + ) + or (self.error_category is None and self.http_status != 200) + ): + raise GatewayAdmissionError("invalid_response", expected_probe) + error_category = ( + self.error_category.value if self.error_category is not None else None + ) + if error_category is not None: + raise GatewayAdmissionError( + error_category, expected_probe, self.http_status + ) + return { + "probe_name": expected_probe, + "http_status": self.http_status, + "result": "passed", + "error_category": None, + } + + +@dataclass +class ExternalGatewayConfig: + """Bootstrap inputs from the trusted workflow, never PR-controlled values.""" + + base_url: str + token_file: Path + require_zdr: bool + + def validate(self) -> None: + """Require an HTTPS origin and private, owned, regular token file.""" + try: + parsed_url = urlsplit(self.base_url) + port_number = parsed_url.port + if ( + any( + ord(character) <= 32 or ord(character) == 127 + for character in self.base_url + ) + or parsed_url.scheme != "https" + or not parsed_url.hostname + or parsed_url.username is not None + or parsed_url.password is not None + or parsed_url.path not in {"", "/"} + or parsed_url.query + or parsed_url.fragment + or (port_number is not None and not 1 <= port_number <= 65535) + or type(self.require_zdr) is not bool + ): + raise ValueError + if not self.token_file.is_absolute() or any( + ord(character) < 32 for character in str(self.token_file) + ): + raise ValueError + token_stat = self.token_file.lstat() + if ( + not stat.S_ISREG(token_stat.st_mode) + or stat.S_IMODE(token_stat.st_mode) != 0o600 + or token_stat.st_uid != os.geteuid() + or not 1 <= token_stat.st_size <= 8192 + ): + raise ValueError + except (OSError, ValueError): + raise GatewayAdmissionError("invalid_gateway_configuration") from None + + +class InferenceProbePort(Protocol): + """Released-adapter boundary; no provider credentials or admin readiness.""" + + def list_models(self) -> ProbeReceipt: + """Validate authenticated discovery and exact orchestrator/free presence. + + Return discovery policy_unavailable when the free pool is absent. + Never return upstream model identifiers or raw response data. + """ + ... + + def probe_capability( + self, capability_name: str, *, model_name: str, require_zdr: bool + ) -> ProbeReceipt: + """Validate a released capability contract using POST /v1/chat/completions. + + The adapter must verify TLS, reject redirects, reopen the private token + without following symlinks, and validate response semantics. It must + not call /readyz or accept an HTTP 200 alone as capability evidence. + """ + ... + + +# CO #1084 is proposed, not a released adapter. Never populate this mapping +# from environment, a PR checkout, downloaded source, or an unverified SHA. +RELEASED_GATEWAY_ADAPTERS: dict[ + str, Callable[[ExternalGatewayConfig], InferenceProbePort] +] = {} + + +def verify_external_gateway( + gateway_config: ExternalGatewayConfig, probe_port: InferenceProbePort +) -> dict: + """Require every inference capability without partial readiness or fallback.""" + gateway_config.validate() + probe_evidence = [] + for probe_name in PROBE_NAMES: + try: + probe_receipt = ( + probe_port.list_models() + if probe_name == "discovery" + else probe_port.probe_capability( + probe_name, + model_name="orchestrator/free", + require_zdr=gateway_config.require_zdr, + ) + ) + except Exception: # noqa: BLE001 - typed failures must use receipts + raise GatewayAdmissionError("invalid_response", probe_name) from None + if type(probe_receipt) is not ProbeReceipt: + raise GatewayAdmissionError("invalid_response", probe_name) + probe_evidence.append(probe_receipt.safe_evidence(probe_name)) + return { + "requested_model": "orchestrator/free", + "capabilities": {name: "passed" for name in PROBE_NAMES[1:]}, + "probes": probe_evidence, + "private_requests_require_zdr": gateway_config.require_zdr, + "policy_evidence": "configured_gateway_policy_only", + } + + +def main() -> int: + """Admit only a source-registered released adapter and publish safe outputs.""" + adapter_revision = os.environ.get( + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION", "" + ) + adapter_factory = RELEASED_GATEWAY_ADAPTERS.get(adapter_revision) + if adapter_factory is None: + print("::error::released_contract_unavailable") + return 1 + try: + zdr_value = os.environ.get("CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR", "false") + if zdr_value not in {"true", "false"}: + raise GatewayAdmissionError("invalid_gateway_configuration") + gateway_config = ExternalGatewayConfig( + os.environ.get("CONTEXTUAL_ORCHESTRATOR_BASE_URL", ""), + Path(os.environ.get("CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE", "")), + zdr_value == "true", + ) + gateway_config.validate() + evidence = verify_external_gateway( + gateway_config, adapter_factory(gateway_config) + ) + runner_temp = os.environ["RUNNER_TEMP"] + if not Path(runner_temp).is_absolute() or any( + ord(character) < 32 for character in runner_temp + ): + raise GatewayAdmissionError("invalid_output_location") + evidence_directory = Path( + tempfile.mkdtemp(prefix="external-review-", dir=runner_temp) + ) + evidence_path = evidence_directory / "preflight-evidence.json" + evidence_path.write_text( + json.dumps({**evidence, "contract_revision": adapter_revision}) + "\n" + ) + evidence_path.chmod(0o600) + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as environment_file: + environment_file.write( + f"CONTEXTUAL_ORCHESTRATOR_BASE_URL={gateway_config.base_url.rstrip('/')}\n" + f"CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE={gateway_config.token_file}\n" + f"CONTEXTUAL_ORCHESTRATOR_PREFLIGHT_EVIDENCE={evidence_path}\n" + f"CONTEXTUAL_ORCHESTRATOR_PRIVATE_REQUESTS_REQUIRE_ZDR={zdr_value}\n" + ) + except GatewayAdmissionError as admission_error: + source_evidence = ( + vars(admission_error).get("evidence") + if type(admission_error) is GatewayAdmissionError + else None + ) + if type(source_evidence) is not dict: + source_evidence = {} + safe_error = GatewayAdmissionError( + source_evidence.get("error_category", "invalid_response"), + source_evidence.get("probe_name", "bootstrap"), + source_evidence.get("http_status"), + ) + print("::error::" + json.dumps(safe_error.evidence)) + return 1 + except Exception: # noqa: BLE001 - bootstrap failures never reveal raw input + print( + "::error::" + + json.dumps( + GatewayAdmissionError("external_gateway_admission_failed").evidence + ) + ) + return 1 + print( + "External gateway inference preflight passed; private requests must retain ZDR policy." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_external_review_gateway.py b/tests/test_external_review_gateway.py new file mode 100644 index 0000000000..198062307d --- /dev/null +++ b/tests/test_external_review_gateway.py @@ -0,0 +1,450 @@ +"""External bootstrap admission stays closed until a released adapter exists.""" + +import importlib +import json +import os +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +def gateway_module(): + """Load the owner port without importing a proposed CO implementation.""" + return importlib.import_module("scripts.ci.external_review_gateway") + + +def gateway_configuration(tmp_path): + """Create a private test credential file, never a provider credential.""" + token_file = tmp_path / "gateway.token" + token_file.write_text("unit-test-gateway-credential") + token_file.chmod(0o600) + return gateway_module().ExternalGatewayConfig( + base_url="https://gateway.example.invalid", + token_file=token_file, + require_zdr=True, + ) + + +def test_unreleased_external_mode_fails_before_provider_secret_bootstrap(tmp_path): + """Opt-in must not fall back to local provider bootstrap or export readiness.""" + output_file = tmp_path / "github-env" + command_result = subprocess.run( + ["bash", "scripts/ci/contextual_orchestrator_review_sidecar.sh"], + env={ + "PATH": os.environ["PATH"], + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ENV": str(output_file), + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_MODE": "external", + }, + capture_output=True, + text=True, + check=False, + ) + assert command_result.returncode == 1 + assert ( + "released_contract_unavailable" in command_result.stdout + command_result.stderr + ) + assert "provider secrets" not in command_result.stdout + command_result.stderr + assert not output_file.exists() + + +@pytest.mark.parametrize( + "base_url", + [ + "http://gateway.invalid", + "https://user@gateway.invalid", + "https://gateway.invalid/path", + "https://gateway.invalid?token=value", + "https://gateway.invalid/#fragment", + "https://gateway.invalid\n", + ], +) +def test_external_origin_rejects_ambiguous_or_insecure_configuration( + tmp_path, base_url +): + """No HTTP, credentials, path, query, fragment or control-byte origin is valid.""" + gateway_config = gateway_configuration(tmp_path) + gateway_config.base_url = base_url + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_config.validate() + + +def test_external_credentials_must_be_private_regular_owned_files(tmp_path): + """A world-readable or symlink credential cannot reach a probe adapter.""" + gateway_config = gateway_configuration(tmp_path) + gateway_config.token_file.chmod(0o644) + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_config.validate() + gateway_config.token_file.chmod(0o600) + token_link = tmp_path / "token-link" + token_link.symlink_to(gateway_config.token_file) + gateway_config.token_file = token_link + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_config.validate() + + +def test_relative_token_reference_cannot_cross_step_boundaries(tmp_path, monkeypatch): + """Later steps may change working directory; token paths must be absolute.""" + gateway_config = gateway_configuration(tmp_path) + gateway_config.token_file = Path("gateway.token") + monkeypatch.chdir(tmp_path) + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_config.validate() + + +@pytest.mark.parametrize( + "missing_capability", ["inventory", "json_object", "json_schema", "tool_call"] +) +def test_probe_failure_never_exports_partial_readiness(tmp_path, missing_capability): + """Every required inference capability is part of one fail-closed result.""" + gateway_config = gateway_configuration(tmp_path) + probe_calls = [] + + def probe_capability(capability_name, *, model_name, require_zdr): + probe_calls.append((capability_name, model_name, require_zdr)) + return gateway_module().ProbeReceipt( + capability_name, + 200, + gateway_module().ProbeErrorCategory.CAPABILITY_UNAVAILABLE + if capability_name == missing_capability + else None, + ) + + probe_port = SimpleNamespace( + list_models=lambda: gateway_module().ProbeReceipt( + "discovery", + 200, + gateway_module().ProbeErrorCategory.POLICY_UNAVAILABLE + if missing_capability == "inventory" + else None, + ), + probe_capability=probe_capability, + ) + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_module().verify_external_gateway(gateway_config, probe_port) + assert all( + model_name == "orchestrator/free" and require_zdr is True + for _, model_name, require_zdr in probe_calls + ) + + +def test_probe_success_reports_only_safe_inference_evidence(tmp_path): + """A port test double cannot inject raw payload or credential evidence.""" + probe_port = SimpleNamespace( + list_models=lambda: gateway_module().ProbeReceipt("discovery", 200), + probe_capability=lambda name, **kwargs: gateway_module().ProbeReceipt( + name, 200 + ), + ) + evidence = gateway_module().verify_external_gateway( + gateway_configuration(tmp_path), probe_port + ) + assert evidence["requested_model"] == "orchestrator/free" + assert evidence["private_requests_require_zdr"] is True + assert evidence["capabilities"] == { + "json_object": "passed", + "json_schema": "passed", + "tool_call": "passed", + } + assert "credential" not in json.dumps(evidence) + assert gateway_module().RELEASED_GATEWAY_ADAPTERS == {} + + +@pytest.mark.parametrize( + "model_inventory", + ["orchestrator/free", {"orchestrator/free": True}, ["orchestrator/free", None]], +) +def test_malformed_inventory_cannot_satisfy_admission(tmp_path, model_inventory): + """Do not interpret substring or dictionary membership as a model list.""" + probe_port = SimpleNamespace( + list_models=lambda: model_inventory, + probe_capability=lambda name, **kwargs: gateway_module().ProbeReceipt( + name, 200 + ), + ) + with pytest.raises(gateway_module().GatewayAdmissionError): + gateway_module().verify_external_gateway( + gateway_configuration(tmp_path), probe_port + ) + + +def test_bootstrap_action_defaults_to_existing_sidecar(): + """Only an explicit mode input admits the future external bootstrap.""" + action_source = Path( + ".github/actions/orchestrator-free-sidecar/action.yml" + ).read_text() + assert 'default: "sidecar"' in action_source + assert ( + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_MODE: ${{ inputs.gateway_mode }}" + in action_source + ) + assert "inputs.gateway_token_file" in action_source + + +def test_unverified_revision_never_constructs_an_adapter(monkeypatch, capsys): + """An arbitrary full SHA is not release authorization.""" + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION", "a" * 40) + assert gateway_module().main() == 1 + assert "released_contract_unavailable" in capsys.readouterr().out + + +def test_registered_port_test_double_exports_only_file_paths( + tmp_path, monkeypatch, capsys +): + """Exercise future publication with an in-memory owner test double only.""" + module = gateway_module() + gateway_config = gateway_configuration(tmp_path) + output_file = tmp_path / "github-env" + probe_port = SimpleNamespace( + list_models=lambda: gateway_module().ProbeReceipt("discovery", 200), + probe_capability=lambda name, **kwargs: gateway_module().ProbeReceipt( + name, 200 + ), + ) + monkeypatch.setattr( + module, + "RELEASED_GATEWAY_ADAPTERS", + {"test_double_revision": lambda config: probe_port}, + ) + for variable_name, variable_value in { + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION": "test_double_revision", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL": gateway_config.base_url, + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE": str(gateway_config.token_file), + "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR": "true", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ENV": str(output_file), + }.items(): + monkeypatch.setenv(variable_name, variable_value) + assert module.main() == 0 + output_text = output_file.read_text() + capsys.readouterr().out + assert "unit-test-gateway-credential" not in output_text + assert "CONTEXTUAL_ORCHESTRATOR_PRIVATE_REQUESTS_REQUIRE_ZDR=true" in output_text + assert "CONTEXTUAL_ORCHESTRATOR_TOKEN=" not in output_text + evidence_files = list(tmp_path.glob("external-review-*/preflight-evidence.json")) + assert len(evidence_files) == 1 + assert "unit-test-gateway-credential" not in evidence_files[0].read_text() + + +def test_adapter_exception_is_sanitized_without_readiness( + tmp_path, monkeypatch, capsys +): + """Raw adapter failures cannot become output, evidence, or a fallback.""" + module = gateway_module() + gateway_config = gateway_configuration(tmp_path) + + def failing_adapter(config): + raise RuntimeError("private-upstream-body-with-secret") + + monkeypatch.setattr( + module, "RELEASED_GATEWAY_ADAPTERS", {"test_double_revision": failing_adapter} + ) + monkeypatch.setenv( + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION", "test_double_revision" + ) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", gateway_config.base_url) + monkeypatch.setenv( + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE", str(gateway_config.token_file) + ) + assert module.main() == 1 + output_text = capsys.readouterr().out + assert "private-upstream" not in output_text + assert "external_gateway_admission_failed" in output_text + + +@pytest.mark.parametrize( + "probe_name", ["discovery", "json_object", "json_schema", "tool_call"] +) +@pytest.mark.parametrize( + "category_name", + [ + "authentication_failed", + "transport_failed", + "invalid_response", + "policy_unavailable", + "capability_unavailable", + ], +) +def test_typed_failure_preserves_stage_and_category( + tmp_path, probe_name, category_name +): + """Adapter failures retain only bounded stage/category/status evidence.""" + module = gateway_module() + category = module.ProbeErrorCategory(category_name) + + def receipt(name): + return ( + module.ProbeReceipt(name, None, category) + if name == probe_name + else module.ProbeReceipt(name, 200) + ) + + port = SimpleNamespace( + list_models=lambda: receipt("discovery"), + probe_capability=lambda name, **kwargs: receipt(name), + ) + with pytest.raises(module.GatewayAdmissionError) as caught: + module.verify_external_gateway(gateway_configuration(tmp_path), port) + assert caught.value.evidence == { + "probe_name": probe_name, + "http_status": None, + "result": "failed", + "error_category": category_name, + } + + +@pytest.mark.parametrize("bad_receipt", [True, {"error_category": "secret"}, "secret"]) +def test_untyped_receipt_fails_without_raw_details(tmp_path, bad_receipt): + """Legacy booleans and raw response mappings cannot satisfy admission.""" + module = gateway_module() + port = SimpleNamespace(list_models=lambda: bad_receipt) + with pytest.raises(module.GatewayAdmissionError) as caught: + module.verify_external_gateway(gateway_configuration(tmp_path), port) + assert caught.value.evidence["error_category"] == "invalid_response" + assert "secret" not in str(caught.value) + + +@pytest.mark.parametrize( + "field,value", + [ + ("probe_name", "secret"), + ("http_status", True), + ("http_status", 600), + ("http_status", "secret"), + ("error_category", "secret"), + ("http_status", None), + ], +) +def test_malformed_receipt_fields_are_sanitized(tmp_path, field, value): + """Dataclass construction alone is not trust-boundary validation.""" + module = gateway_module() + values = {"probe_name": "discovery", "http_status": 200, "error_category": None} + values[field] = value + port = SimpleNamespace(list_models=lambda: module.ProbeReceipt(**values)) + with pytest.raises(module.GatewayAdmissionError) as caught: + module.verify_external_gateway(gateway_configuration(tmp_path), port) + assert caught.value.evidence == { + "probe_name": "discovery", + "http_status": None, + "result": "failed", + "error_category": "invalid_response", + } + + +@pytest.mark.parametrize( + "failure_kind", + [ + "authentication_failed", + "transport_failed", + "invalid_response", + "policy_unavailable", + "capability_unavailable", + "raw_exception", + "malformed", + ], +) +@pytest.mark.parametrize("failing_stage", ["discovery", "json_schema"]) +def test_main_preserves_safe_failure_and_never_exports_readiness( + tmp_path, monkeypatch, capsys, failure_kind, failing_stage +): + """Main retains validated probe failure details without exception text.""" + module = gateway_module() + config = gateway_configuration(tmp_path) + + def probe(name): + if name != failing_stage: + return module.ProbeReceipt(name, 200) + if failure_kind == "raw_exception": + raise RuntimeError("private-upstream-body-with-secret") + if failure_kind == "malformed": + return module.ProbeReceipt("secret", "secret", "secret") + return module.ProbeReceipt(name, None, module.ProbeErrorCategory(failure_kind)) + + monkeypatch.setattr( + module, + "RELEASED_GATEWAY_ADAPTERS", + { + "test": lambda config: SimpleNamespace( + list_models=lambda: probe("discovery"), + probe_capability=lambda name, **kwargs: probe(name), + ) + }, + ) + for key, value in { + "CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION": "test", + "CONTEXTUAL_ORCHESTRATOR_BASE_URL": config.base_url, + "CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE": str(config.token_file), + "RUNNER_TEMP": str(tmp_path), + "GITHUB_ENV": str(tmp_path / "github-env"), + }.items(): + monkeypatch.setenv(key, value) + assert module.main() == 1 + output = capsys.readouterr().out + assert "secret" not in output + assert f'"probe_name": "{failing_stage}"' in output + expected_category = ( + "invalid_response" + if failure_kind in {"raw_exception", "malformed"} + else failure_kind + ) + assert f'"error_category": "{expected_category}"' in output + assert not (tmp_path / "github-env").exists() + assert not list(tmp_path.glob("external-review-*")) + + +@pytest.mark.parametrize( + "tampered_evidence", + [ + { + "probe_name": "discovery", + "http_status": 401, + "result": "secret", + "error_category": "authentication_failed", + "raw_body": "secret", + }, + {"probe_name": "secret", "http_status": "secret", "error_category": "secret"}, + "secret", + "missing", + "inaccessible", + ], +) +def test_main_projects_factory_admission_errors( + tmp_path, monkeypatch, capsys, tampered_evidence +): + """Mutable exception evidence cannot add raw fields at final serialization.""" + module = gateway_module() + config = gateway_configuration(tmp_path) + + def factory(config): + error = module.GatewayAdmissionError("authentication_failed") + if tampered_evidence == "missing": + del error.evidence + error.args = ("secret",) + elif tampered_evidence == "inaccessible": + + class InaccessibleAdmissionError(module.GatewayAdmissionError): + def __getattribute__(self, field_name): + if field_name == "evidence": + raise RuntimeError("secret") + return super().__getattribute__(field_name) + + error = InaccessibleAdmissionError("authentication_failed") + error.args = ("secret",) + else: + error.evidence = tampered_evidence + raise error + + monkeypatch.setattr(module, "RELEASED_GATEWAY_ADAPTERS", {"test": factory}) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_GATEWAY_CONTRACT_REVISION", "test") + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_BASE_URL", config.base_url) + monkeypatch.setenv("CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE", str(config.token_file)) + output_file = tmp_path / "github-env" + monkeypatch.setenv("GITHUB_ENV", str(output_file)) + assert module.main() == 1 + output = capsys.readouterr().out + assert "secret" not in output + evidence = json.loads(output.removeprefix("::error::")) + assert set(evidence) == {"probe_name", "http_status", "result", "error_category"} + assert evidence["result"] == "failed" + assert not output_file.exists()