diff --git a/contextual_orchestrator/__init__.py b/contextual_orchestrator/__init__.py index 70dbd71c6..0bacc8e5d 100644 --- a/contextual_orchestrator/__init__.py +++ b/contextual_orchestrator/__init__.py @@ -36,7 +36,14 @@ ) from .cost_router import CostRoutingCoordinator from .credentials import NotConfigured, get_credential, register_credential -from .kv_config import InMemoryConfigStore, get_config_store +from .kv_config import ( + InMemoryConfigStore, + allowed_provider_hosts, + get_config_store, + get_config_value, + set_config_value, + set_runtime_config_store, +) from .orchestrator import ModelAgent, TaskOrchestrator, WorkflowStep, load_agents from .token_counting import HeuristicTokenCounter, build_token_counter @@ -67,6 +74,10 @@ # config / tokens "InMemoryConfigStore", "get_config_store", + "get_config_value", + "set_config_value", + "set_runtime_config_store", + "allowed_provider_hosts", "HeuristicTokenCounter", "build_token_counter", # routing / batch diff --git a/contextual_orchestrator/__main__.py b/contextual_orchestrator/__main__.py index 5f68c3b74..342d6e79f 100644 --- a/contextual_orchestrator/__main__.py +++ b/contextual_orchestrator/__main__.py @@ -8,6 +8,11 @@ import sys from .credentials import register_credential +from .kv_config import ( + allowed_provider_hosts, + get_config_store, + set_runtime_config_store, +) from .orchestrator import ModelClient, TaskOrchestrator, load_agents from .server import SecurityConfig, serve @@ -119,6 +124,15 @@ def main() -> None: args.admin_token and args.inference_token ): parser.error("split token mode requires both --admin-token and --inference-token") + set_runtime_config_store( + get_config_store( + os.environ.get("CONTEXTUAL_ORCHESTRATOR_KV_DSN") or None, + fernet_key=( + os.environ.get("CONTEXTUAL_ORCHESTRATOR_KV_PASSPHRASE") or None + ), + ) + ) + allowed_provider_hosts() serve( orchestrator, host=args.host, diff --git a/contextual_orchestrator/cost_ledger.py b/contextual_orchestrator/cost_ledger.py index d3943c5be..8ca3c6dfd 100644 --- a/contextual_orchestrator/cost_ledger.py +++ b/contextual_orchestrator/cost_ledger.py @@ -583,12 +583,12 @@ def _seed_dimension_catalog(self) -> None: ph = self._placeholder() cur = self._conn.cursor() for order, (name, label, _column) in enumerate(ATTRIBUTION_DIMENSION_CATALOG): - cur.execute( + cur.execute( # nosemgrep -- sqlalchemy-execute-raw-query FP: only the DB-API placeholder char is interpolated; the value is bound. f"SELECT 1 FROM cost_attribution_dimensions WHERE dimension_name = {ph}", # nosec B608 - ph is a DB-API placeholder. (name,), ) if cur.fetchone() is None: - cur.execute( + cur.execute( # nosemgrep -- sqlalchemy-execute-raw-query FP: only DB-API placeholder chars are interpolated; values are bound. "INSERT INTO cost_attribution_dimensions " f"(dimension_name, dimension_label, dimension_order) VALUES ({ph}, {ph}, {ph})", # nosec B608 - ph is a DB-API placeholder. (name, label, order), @@ -602,7 +602,7 @@ def append(self, record: UsageRecord) -> None: placeholders = ", ".join(ph for _ in _USAGE_COLUMNS) columns = ", ".join(_USAGE_COLUMNS) cur = self._conn.cursor() - cur.execute( + cur.execute( # nosemgrep -- sqlalchemy-execute-raw-query FP: columns are the fixed _USAGE_COLUMNS constant; values are bound. f"INSERT INTO llm_usage_records ({columns}) VALUES ({placeholders})", # nosec B608 - columns are fixed _USAGE_COLUMNS. tuple(row.get(column) for column in _USAGE_COLUMNS), ) @@ -622,7 +622,7 @@ def query(self, start: Optional[int] = None, end: Optional[int] = None) -> List[ where = f" WHERE {' AND '.join(clauses)}" if clauses else "" columns = ", ".join(_USAGE_COLUMNS) cur = self._conn.cursor() - cur.execute(f"SELECT {columns} FROM llm_usage_records{where}", tuple(params)) # nosec B608 - columns and clauses are fixed. + cur.execute(f"SELECT {columns} FROM llm_usage_records{where}", tuple(params)) # nosec B608 - columns and clauses are fixed. # nosemgrep -- sqlalchemy-execute-raw-query FP: fixed columns and clause templates; all values are bound. return [dict(zip(_USAGE_COLUMNS, values)) for values in cur.fetchall()] diff --git a/contextual_orchestrator/kv_config.py b/contextual_orchestrator/kv_config.py index fbb7278ae..20064f8ea 100644 --- a/contextual_orchestrator/kv_config.py +++ b/contextual_orchestrator/kv_config.py @@ -147,3 +147,68 @@ def get_config_store( return adapter except Exception: # pragma: no cover - fall back when deps/DB unavailable return InMemoryConfigStore(seed=seed) + + +# Process-wide runtime config (mirrors credentials.set_backend for non-secret KV). +_runtime_config_store: Optional[ConfigStore] = None + + +def get_runtime_config_store() -> ConfigStore: + """Return the process runtime config store (never secrets). + + Defaults to an empty in-memory store. Tests inject via + :func:`set_runtime_config_store`. Request-time code must read tunables from + here (or an injected store), not ``os.getenv``. + """ + global _runtime_config_store + if _runtime_config_store is None: + _runtime_config_store = InMemoryConfigStore() + return _runtime_config_store + + +def set_runtime_config_store(store: Optional[ConfigStore]) -> None: + """Install or clear the process runtime config store (tests / bootstrap).""" + global _runtime_config_store + _runtime_config_store = store + + +def get_config_value(category: str, key: str, default: Any = None) -> Any: + """Read one runtime config value from the process KV store.""" + return get_runtime_config_store().get(category, key, default) + + +def set_config_value(category: str, key: str, value: Any) -> None: + """Write one runtime config value into the process KV store.""" + get_runtime_config_store().set(category, key, value) + + +PROVIDER_ALLOWED_HOSTS_KEY = "allowed_hosts" +PROVIDER_CONFIG_CATEGORY = "provider" +# Bootstrap-only env name: may seed the KV once, never used as the runtime source. +ALLOWED_PROVIDER_HOSTS_ENV = "CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS" + + +def allowed_provider_hosts() -> set[str]: + """Return the operator host allowlist from the runtime KV config store. + + Empty set means no extra host filter (public-IP checks still apply). When the + KV has no value yet, a one-shot bootstrap may seed from + ``CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS`` into the store and then + only the store is authoritative. + """ + import os + + store = get_runtime_config_store() + raw = store.get(PROVIDER_CONFIG_CATEGORY, PROVIDER_ALLOWED_HOSTS_KEY, None) + if raw is None: + # One-shot bootstrap: seed even when the env is empty so a later env + # mutation cannot re-seed the store after first read (KV purity). + env_val = os.environ.get(ALLOWED_PROVIDER_HOSTS_ENV, "") + seeded = env_val.strip() if isinstance(env_val, str) else "" + store.set(PROVIDER_CONFIG_CATEGORY, PROVIDER_ALLOWED_HOSTS_KEY, seeded) + raw = seeded + if isinstance(raw, (list, tuple, set)): + parts = [str(item) for item in raw] + else: + parts = str(raw).split(",") + return {part.strip().lower() for part in parts if part and str(part).strip()} diff --git a/contextual_orchestrator/orchestrator.py b/contextual_orchestrator/orchestrator.py index 0097b722e..e41781683 100644 --- a/contextual_orchestrator/orchestrator.py +++ b/contextual_orchestrator/orchestrator.py @@ -27,6 +27,7 @@ from .conventions import require_object_name from .credentials import NotConfigured, get_credential +from .kv_config import allowed_provider_hosts ChatMessage = dict[str, str] @@ -230,7 +231,7 @@ def __init__( @staticmethod def _build_ssl_context(ca_bundle: str | None, verify_tls: bool) -> ssl.SSLContext: if not verify_tls: - return ssl._create_unverified_context() # nosec B323 - explicit dev-only provider TLS opt-out. + return ssl._create_unverified_context() # nosec B323 - explicit dev-only provider TLS opt-out. # nosemgrep -- unverified-ssl-context: intentional, default-secure (verify_tls defaults True) dev-only opt-out for self-signed endpoints. if ca_bundle: if not os.path.isfile(ca_bundle): raise ValueError(f"provider CA bundle does not exist: {ca_bundle}") @@ -307,7 +308,7 @@ def _send(self, agent: ModelAgent, payload: dict[str, Any]) -> str: def _open_provider(self, request: urllib.request.Request) -> Any: """Open a provider request built from a validated provider URL.""" - return urllib.request.urlopen( # nosec B310 - request URL comes from _provider_url after provider validation. + return urllib.request.urlopen( # nosec B310 - request URL comes from _provider_url after provider validation. # nosemgrep -- dynamic-urllib-use: URL is built by _provider_url after scheme/host validation; egress to loopback/private/reserved is blocked. request, timeout=self.timeout, context=self._ssl_context, @@ -463,11 +464,8 @@ def _validate_provider(self, agent: ModelAgent) -> None: parsed = urlparse(agent.base_url) if parsed.scheme != "https" or not parsed.hostname: raise RuntimeError(f"{agent.id} base_url must use https") - allowed_hosts = { - host.strip().lower() - for host in os.environ.get("CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS", "").split(",") - if host.strip() - } + # Host allowlist is KV-backed (provider/allowed_hosts); env is bootstrap-only. + allowed_hosts = allowed_provider_hosts() hostname = parsed.hostname.lower() if allowed_hosts and hostname not in allowed_hosts: raise RuntimeError(f"{agent.id} provider host is not allowlisted") diff --git a/docs/architecture.md b/docs/architecture.md index c0f63a81e..db3e11aef 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,3 +53,7 @@ The product is not a Fugu clone. It is a control-plane prototype for the same pu - replayable evaluation runs before any learned coordinator replaces the deterministic policy. See [product_planning.md](product_planning.md) for the product reboot. + +## Provider host allowlist (KV) + +`ModelClient._validate_provider` reads `provider/allowed_hosts` from the runtime config KV via `allowed_provider_hosts()`. Env `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS` is bootstrap-only. diff --git a/docs/kv-credentials.md b/docs/kv-credentials.md index 6860aeeec..57bf872b8 100644 --- a/docs/kv-credentials.md +++ b/docs/kv-credentials.md @@ -74,19 +74,25 @@ CREATE TABLE IF NOT EXISTS provider_credentials ( Secrets are encrypted at rest with `pgp_sym_encrypt(value, passphrase)` and read back with `pgp_sym_decrypt(encrypted_value, passphrase)`. -## The single allowed env use: bootstrap transport - -Environment variables are permitted in **exactly one place** — as bootstrap -transport to *connect to and unlock the KV itself*, never as the runtime source -of a provider key: - -| Variable | Role | -| ------------------------------------------ | -------------------------------------- | -| `CONTEXTUAL_ORCHESTRATOR_KV_BACKEND` | backend selector (`memory`/`postgres`) | -| `CONTEXTUAL_ORCHESTRATOR_KV_DSN` | Postgres DSN to reach the registry | -| `CONTEXTUAL_ORCHESTRATOR_KV_PASSPHRASE` | pgcrypto passphrase to unlock secrets | - -These open the KV. They are not provider API keys. +## Allowed environment use: bootstrap transport and one-shot config seeding + +Environment variables are permitted only during process bootstrap. They may +select, connect to, and unlock the KV, or seed the non-secret provider host +allowlist exactly once. They are never the request-time source of a provider +key or mutable request-time policy: + +| Variable | Bootstrap role | +| -------- | -------------- | +| `CONTEXTUAL_ORCHESTRATOR_KV_BACKEND` | Select the `memory` or `postgres` credential backend. | +| `CONTEXTUAL_ORCHESTRATOR_KV_DSN` | Connect to the Postgres registry. | +| `CONTEXTUAL_ORCHESTRATOR_KV_PASSPHRASE` | Unlock encrypted provider credentials. | +| `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS` | Seed `provider/allowed_hosts` once when that KV key is initially absent. | + +The first three values select or open the KV. The provider-host value is a +one-shot bootstrap input copied into the runtime config store. After store +installation and first seeding, the KV value is authoritative and later +environment mutations have no effect. None of these values is a provider API +key. ## Bootstrapping a credential @@ -155,3 +161,28 @@ This credential seam is the durable first step of growing per-tenant scoping can grow behind without touching the routing engine. The Rust/Python hybrid gateway is a later, separately-approved effort and is **not** started here. + +## Non-secret runtime config (provider host allowlist) + +Operator tunables that are not secrets also resolve from the KV config store +(`get_runtime_config_store` / `get_config_value`), never from `os.getenv` at +request time. + +| Category | Key | Purpose | +| -------- | --- | ------- | +| `provider` | `allowed_hosts` | Comma-separated or list of HTTPS provider hostnames allowed for egress. Empty = no extra filter (public-IP checks still apply). | + +Bootstrap: if `provider/allowed_hosts` is unset, the first read may seed the store +from `CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS`. After seeding, only the +store is authoritative; live environment mutations do not change request-time +policy. + +```python +from contextual_orchestrator.kv_config import set_config_value + +set_config_value( + "provider", + "allowed_hosts", + "api.openai.com,integrate.api.nvidia.com", +) +``` diff --git a/pyproject.toml b/pyproject.toml index 65bd69eac..cfa6e9063 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,3 +44,9 @@ ignore-nested-functions = true ignore-private = true ignore-property-decorators = true ignore-semiprivate = true + +[tool.ruff.lint.per-file-ignores] +# The ledger interpolates only fixed column catalogs and DB-API placeholder +# tokens; all request values remain bound parameters. Keep the suppression +# scoped to the single audited module and the single false-positive rule. +"contextual_orchestrator/cost_ledger.py" = ["S608"] diff --git a/tests/test_kv_provider_hosts.py b/tests/test_kv_provider_hosts.py new file mode 100644 index 000000000..c3d7e2baf --- /dev/null +++ b/tests/test_kv_provider_hosts.py @@ -0,0 +1,147 @@ +"""KV-backed provider host allowlist (runtime purity).""" + +from __future__ import annotations + +import os +from pathlib import Path +import sys +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from contextual_orchestrator.credentials import InMemoryCredentialBackend, set_backend +from contextual_orchestrator.kv_config import ( + ALLOWED_PROVIDER_HOSTS_ENV, + InMemoryConfigStore, + allowed_provider_hosts, + get_runtime_config_store, + set_config_value, + set_runtime_config_store, +) +from contextual_orchestrator.orchestrator import ModelAgent, ModelClient + + +def test_allowed_hosts_read_from_kv_not_live_env_mutation() -> None: + """Ignore request-time environment mutations after the KV is installed.""" + set_runtime_config_store(InMemoryConfigStore()) + set_config_value("provider", "allowed_hosts", "api.example.com") + previous = os.environ.get(ALLOWED_PROVIDER_HOSTS_ENV) + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = "evil.example" + try: + hosts = allowed_provider_hosts() + assert hosts == {"api.example.com"} + assert "evil.example" not in hosts + finally: + if previous is None: + os.environ.pop(ALLOWED_PROVIDER_HOSTS_ENV, None) + else: + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = previous + set_runtime_config_store(None) + + +def test_bootstrap_env_seeds_kv_once() -> None: + """Seed the store once and keep the seeded value after env removal.""" + set_runtime_config_store(InMemoryConfigStore()) + previous = os.environ.get(ALLOWED_PROVIDER_HOSTS_ENV) + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = "seeded.example.com" + try: + hosts = allowed_provider_hosts() + assert hosts == {"seeded.example.com"} + os.environ.pop(ALLOWED_PROVIDER_HOSTS_ENV, None) + assert allowed_provider_hosts() == {"seeded.example.com"} + finally: + if previous is None: + os.environ.pop(ALLOWED_PROVIDER_HOSTS_ENV, None) + else: + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = previous + set_runtime_config_store(None) + + +def test_empty_env_bootstrap_locks_out_later_env_injection() -> None: + """Empty bootstrap must still seed KV so post-start env cannot inject hosts.""" + set_runtime_config_store(InMemoryConfigStore()) + previous = os.environ.get(ALLOWED_PROVIDER_HOSTS_ENV) + os.environ.pop(ALLOWED_PROVIDER_HOSTS_ENV, None) + try: + assert allowed_provider_hosts() == set() + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = "evil.example" + assert allowed_provider_hosts() == set() + assert "evil.example" not in allowed_provider_hosts() + finally: + if previous is None: + os.environ.pop(ALLOWED_PROVIDER_HOSTS_ENV, None) + else: + os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = previous + set_runtime_config_store(None) + + +def test_validate_provider_uses_kv_allowlist() -> None: + """Reject a credentialed provider whose host is absent from the KV policy.""" + backend = InMemoryCredentialBackend() + backend.set("MODEL_KEY", "sk-test") + set_backend(backend) + set_runtime_config_store(InMemoryConfigStore()) + set_config_value("provider", "allowed_hosts", ["allowed.example"]) + client = ModelClient() + agent = ModelAgent("remote_agent", "m", "https://other.example/v1", "MODEL_KEY") + try: + try: + client._validate_provider(agent) + except RuntimeError as exc: + assert "allowlisted" in str(exc) + else: + raise AssertionError("unlisted host should fail") + finally: + set_backend(None) + set_runtime_config_store(None) + + +def test_serve_installs_runtime_config_store_from_bootstrap() -> None: + """Install and eagerly seed the runtime KV before accepting requests.""" + import contextual_orchestrator.__main__ as cli + + configured_store = InMemoryConfigStore() + argv = [ + "contextual_orchestrator", + "--serve", + "--auth-token", + "test-token", + ] + bootstrap_env = { + "CONTEXTUAL_ORCHESTRATOR_KV_DSN": "postgresql://config.example/db", + "CONTEXTUAL_ORCHESTRATOR_KV_PASSPHRASE": "bootstrap-passphrase", + ALLOWED_PROVIDER_HOSTS_ENV: "", + } + try: + with patch.object(cli.sys, "argv", argv), patch.dict( + cli.os.environ, bootstrap_env, clear=False + ), patch.object( + cli, "get_config_store", return_value=configured_store + ) as get_store, patch.object( + cli, "load_agents", return_value=[] + ), patch.object( + cli, "ModelClient", return_value=object() + ), patch.object( + cli, "TaskOrchestrator", return_value=object() + ), patch.object(cli, "serve") as serve_server: + cli.main() + cli.os.environ[ALLOWED_PROVIDER_HOSTS_ENV] = "evil.example" + assert allowed_provider_hosts() == set() + + get_store.assert_called_once_with( + "postgresql://config.example/db", + fernet_key="bootstrap-passphrase", + ) + assert get_runtime_config_store() is configured_store + serve_server.assert_called_once() + finally: + set_runtime_config_store(None) + + +if __name__ == "__main__": + test_allowed_hosts_read_from_kv_not_live_env_mutation() + test_bootstrap_env_seeds_kv_once() + test_empty_env_bootstrap_locks_out_later_env_injection() + test_validate_provider_uses_kv_allowlist() + test_serve_installs_runtime_config_store_from_bootstrap() + print("ok") diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index 67134ea6b..717db6953 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -255,15 +255,21 @@ def test_external_provider_requires_resolvable_credential_and_public_https() -> def test_external_provider_rejects_insecure_or_unlisted_hosts() -> None: + from contextual_orchestrator.kv_config import ( + InMemoryConfigStore, + set_config_value, + set_runtime_config_store, + ) + client = ModelClient() insecure_agent = ModelAgent("insecure_agent", "gpt-example", "http://api.openai.com/v1", "MODEL_KEY") unlisted_agent = ModelAgent("unlisted_agent", "gpt-example", "https://api.openai.com/v1", "MODEL_KEY") - previous = os.environ.get("CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS") - os.environ["CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS"] = "example.com" # Register the credential so validation proceeds to the host-safety checks. backend = InMemoryCredentialBackend() backend.set("MODEL_KEY", "sk-host-check") set_backend(backend) + set_runtime_config_store(InMemoryConfigStore()) + set_config_value("provider", "allowed_hosts", "example.com") try: try: @@ -281,10 +287,7 @@ def test_external_provider_rejects_insecure_or_unlisted_hosts() -> None: raise AssertionError("unlisted provider should fail") finally: set_backend(None) - if previous is None: - os.environ.pop("CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS", None) - else: - os.environ["CONTEXTUAL_ORCHESTRATOR_ALLOWED_PROVIDER_HOSTS"] = previous + set_runtime_config_store(None) def test_provider_transport_rejects_local_url_schemes_before_urllib() -> None: