diff --git a/CHANGELOG.md b/CHANGELOG.md index 10fafd1..1c8afc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to FoundryGate should be documented here. The format is intentionally lightweight and human-readable. Group entries by release and focus on user-visible behavior, operational changes, and compatibility notes. +## Unreleased + +### Added + +- Added dashboard CSP hashes plus stricter response-security defaults for the no-build operator UI +- Added stronger provider base URL validation so non-local upstreams must use `https` +- Added reduced leakage of upstream provider failure details in client-facing error payloads +- Added a separate npm CLI package under `packages/foundrygate-cli` for basic health, model, update, and route-preview checks +- Added a documented `v1.0.0` security review with mitigations and residual-risk notes +- Added functional API coverage for upstream error sanitization on top of the earlier dashboard and request-boundary hardening tests + ## v0.9.0 - 2026-03-15 ### Added diff --git a/README.md b/README.md index 30f07ad..1a17360 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ If every configured provider API key is empty, FoundryGate still starts, but it - [Integrations](./docs/INTEGRATIONS.md) - [Onboarding](./docs/ONBOARDING.md) - [Publishing](./docs/PUBLISHING.md) +- [Security Review](./docs/SECURITY-REVIEW-v1.0.0.md) - [Troubleshooting](./docs/TROUBLESHOOTING.md) - [Roadmap](./docs/FOUNDRYGATE-ROADMAP.md) @@ -845,6 +846,17 @@ python -m build Tagged releases always build Python artifacts. PyPI publishing is wired behind the repository variable `PYPI_PUBLISH=true` plus GitHub trusted publishing for the `pypi` environment. +### Separate npm CLI Package + +`v1.0.0` adds a separate npm-facing CLI package in [packages/foundrygate-cli](./packages/foundrygate-cli). + +The package stays intentionally small and separate from the Python gateway core. It currently focuses on operator- and integration-friendly commands such as: + +- `foundrygate-cli health` +- `foundrygate-cli models` +- `foundrygate-cli update` +- `foundrygate-cli route --message "..."` + ## Publishing FoundryGate now has a real publish dry-run path for both Python artifacts and the container image. @@ -877,6 +889,17 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin | `foundrygate-doctor` | Checks for config/env presence, writable DB path, at least one configured provider key, and optional local health endpoints | | `foundrygate-onboarding-report` | Summarizes provider readiness, staged rollout readiness, client-profile coverage, client match intent, routing layers, onboarding suggestions, and concrete OpenClaw/n8n/CLI quickstarts | | `foundrygate-onboarding-validate` | Exits non-zero when onboarding blockers exist and prints warnings for common multi-provider and multi-client misconfigurations | +| `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service | +| `foundrygate-start` | Runs `systemctl start foundrygate.service` | +| `foundrygate-stop` | Runs `systemctl stop foundrygate.service` | +| `foundrygate-restart` | Runs `systemctl restart foundrygate.service` | +| `foundrygate-status` | Shows service status and checks whether `127.0.0.1:8090` is listening | +| `foundrygate-logs` | Tails `journalctl -u foundrygate.service` | +| `foundrygate-health` | Calls `GET /health` locally with `curl` | +| `foundrygate-update-check` | Calls `GET /api/update` locally and prints the cached release-check status | +| `foundrygate-auto-update` | Evaluates the cached update status and, with `--apply`, only runs the configured update command when the release is eligible | +| `foundrygate-update` | Fetches from Git, hard-resets to `origin/main`, cleans untracked files, reinstalls the unit, restarts, and retries health checks | +| `foundrygate-uninstall` | Stops and disables the service, removes the unit file, and removes helper symlinks | Provider starter snippets for the first rollout path live under [docs/examples](./docs/examples): @@ -897,17 +920,6 @@ For delegated OpenClaw traffic and future AI-native app profiles, the new starte - [openclaw-delegated-request.json](./docs/examples/openclaw-delegated-request.json) - [client-ai-native-app-profile.yaml](./docs/examples/client-ai-native-app-profile.yaml) -| `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service | -| `foundrygate-start` | Runs `systemctl start foundrygate.service` | -| `foundrygate-stop` | Runs `systemctl stop foundrygate.service` | -| `foundrygate-restart` | Runs `systemctl restart foundrygate.service` | -| `foundrygate-status` | Shows service status and checks whether `127.0.0.1:8090` is listening | -| `foundrygate-logs` | Tails `journalctl -u foundrygate.service` | -| `foundrygate-health` | Calls `GET /health` locally with `curl` | -| `foundrygate-update-check` | Calls `GET /api/update` locally and prints the cached release-check status | -| `foundrygate-auto-update` | Evaluates the cached update status and, with `--apply`, only runs the configured update command when the release is eligible | -| `foundrygate-update` | Fetches from Git, hard-resets to `origin/main`, cleans untracked files, reinstalls the unit, restarts, and retries health checks | -| `foundrygate-uninstall` | Stops and disables the service, removes the unit file, and removes helper symlinks | `foundrygate-stats --json` now also includes client/profile breakdowns alongside provider and routing summaries. @@ -963,6 +975,7 @@ Security automation and review baseline: - [CodeQL](./.github/workflows/codeql.yml) provides code scanning on `main`, pull requests, and a weekly schedule - [Dependabot](./.github/dependabot.yml) tracks Python, GitHub Actions, and Docker dependencies - GitHub secret scanning is already active at the repository level +- [Security Review](./docs/SECURITY-REVIEW-v1.0.0.md) captures the `v1.0.0` release-gate review and residual-risk summary ## Repo Safety And CI @@ -1053,13 +1066,13 @@ Short version: - the completed foundation already covers capability-aware routing, local worker support, client profiles, request hooks, route introspection, route traces, local worker probing, and first multi-dimensional route-fit checks - `v0.4.x` now focuses on deeper route scoring and dashboard refinement rather than the initial hook/dashboard baseline - `v0.5.0` is the operator distribution baseline for Docker and PyPI publishing, onboarding helpers, and release update checks -- the path to `v1.0.0` includes modality expansion, update operations, a separate npm or TypeScript CLI package, and a full security review +- `v1.0.0` is the stable gateway baseline with the separate npm CLI package and the completed security review gate ## Releases - [CHANGELOG.md](./CHANGELOG.md) tracks notable user-facing changes - [RELEASES.md](./RELEASES.md) describes the lightweight release process for tags and GitHub Releases -- publishing path: GitHub Releases now, Docker and PyPI in `v0.5.0`, separate npm or TypeScript CLI package by `v1.0.0` +- publishing path: GitHub Releases, Docker, and PyPI are established, and `v1.0.0` adds the separate npm CLI package under `packages/foundrygate-cli` - GitHub Releases: [https://github.com/typelicious/FoundryGate/releases](https://github.com/typelicious/FoundryGate/releases) ## Contributing diff --git a/RELEASES.md b/RELEASES.md index c4919e0..c703f48 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -67,9 +67,9 @@ The repo also includes [publish-dry-run](./.github/workflows/publish-dry-run.yml - `v0.6.0`: modality-aware image routing becomes an explicit release line with provider inventory and image-policy guidance. - `v0.7.0`: helper-driven update controls become a first-class release line with scoped rollout gates and verification hooks. - `v0.8.0`: many-provider and many-client onboarding becomes copy/pasteable and validation-backed through reports, starters, and doctor checks. -- `v1.0.0`: keep GitHub Releases, Docker, and PyPI, and add a separate npm or TypeScript CLI package if the CLI surface is ready. +- `v1.0.0`: keep GitHub Releases, Docker, and PyPI, and add the separate npm CLI package under `packages/foundrygate-cli`. -The npm or TypeScript package should stay separate from the Python gateway core. It is meant for CLI-facing integrations, not for rewriting the service runtime. +The npm package stays separate from the Python gateway core. It is meant for CLI-facing integrations, not for rewriting the service runtime. ## Scheduled Deployment Examples diff --git a/SECURITY.md b/SECURITY.md index 25a9a31..71c01e6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,6 +37,8 @@ Please report issues such as: - dependency vulnerabilities with practical impact - trust-boundary issues between FoundryGate and upstream or local providers +For the `v1.0.0` release gate, the reviewed findings and residual risks are summarized in [docs/SECURITY-REVIEW-v1.0.0.md](./docs/SECURITY-REVIEW-v1.0.0.md). + ## Operational Guidance To reduce risk in deployments: diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index e5e157d..cdbf08e 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -19,7 +19,7 @@ The foundation that used to be the near-term buildout is largely in place: This roadmap now shifts from "rename and foundation" to "deepen the gateway plane without bloating it". -`v0.9.x` is the current release line: the focus now shifts to pre-`v1.0` hardening across request boundaries, functional API coverage, and a full documentation pass on the already-shipped routing, modality, onboarding, and ops foundation. +`v1.0.0` is the current release line: the focus now shifts from feature accretion to a stable gateway baseline, a completed security review gate, and a separate npm CLI surface for Node-facing integrations. ## Big Picture @@ -266,7 +266,7 @@ Current `v0.9.x` baseline is aimed at: Primary goals: - declare a stable FoundryGate gateway baseline for local-first, multi-provider routing -- publish the first separate npm or TypeScript CLI package for FoundryGate-adjacent CLI usage +- publish the first separate npm CLI package for FoundryGate-adjacent CLI usage - complete a comprehensive security review before release The `v1.0.0` security review should explicitly cover: @@ -279,6 +279,14 @@ The `v1.0.0` security review should explicitly cover: `v1.0.0` should only ship after those review results are addressed or documented with a clear mitigation plan. +Current `v1.0.0` baseline is aimed at: + +- dashboard CSP hardening without turning the no-build UI into a separate frontend app +- reduced leakage of upstream provider failure details in client responses +- clearer trust-boundary validation for provider base URLs +- a documented release-gate security review with explicit residual risks +- a separate npm CLI package that complements the Python gateway instead of replacing it + ## Updated near-term PR sequence The next sequence should ladder directly into the release path above: diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index cf9ff2e..a984fc4 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -97,6 +97,8 @@ For a reusable shell starter, use [examples/cli-foundrygate-env.sh](./examples/c As with other clients, prefer token-like client tags over long free-form values so the bounded header surface remains readable in traces and operator views. +If you want a small Node-facing helper instead of shell aliases, the separate npm package lives in [packages/foundrygate-cli](../packages/foundrygate-cli). + ## AI-native app clients For future app-specific clients, keep the same OpenAI-compatible base URL and add one stable app header before creating multiple custom profiles. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 0a7bbe0..aad3a43 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -9,6 +9,7 @@ FoundryGate currently ships through: - Git tags and GitHub Releases - Python distributions (`sdist` and `wheel`) - a GHCR container image +- a separate npm CLI package in [packages/foundrygate-cli](../packages/foundrygate-cli) PyPI remains opt-in and only publishes when trusted publishing is configured and `PYPI_PUBLISH=true` is set at the repository level. @@ -45,6 +46,7 @@ The real publish flow stays tag-driven through [release-artifacts](../.github/wo 4. let `release-artifacts` build Python distributions and the GHCR image 5. publish the GitHub Release 6. optionally allow PyPI publication through trusted publishing +7. publish the separate npm CLI package only when you are ready to version the Node-facing surface independently ## Trust Boundaries diff --git a/docs/SECURITY-REVIEW-v1.0.0.md b/docs/SECURITY-REVIEW-v1.0.0.md new file mode 100644 index 0000000..6e10140 --- /dev/null +++ b/docs/SECURITY-REVIEW-v1.0.0.md @@ -0,0 +1,82 @@ +# FoundryGate v1.0.0 Security Review + +## Scope + +This review covers the release-gate areas called out in the roadmap for `v1.0.0`: + +- dashboard XSS and HTML/CSS injection +- request, header, and parameter injection +- dependency and unsafe-default review +- local-worker and upstream trust boundaries +- auth, secret-handling, and writable-path assumptions + +## Findings And Outcomes + +### 1. Dashboard XSS / HTML / CSS injection + +Status: mitigated in the current runtime baseline. + +- the built-in dashboard remains a static no-build page +- dynamic values are escaped before insertion into the DOM +- the dashboard now ships with a restrictive CSP using content hashes instead of `unsafe-inline` +- `X-Frame-Options: DENY` and `Referrer-Policy: no-referrer` are enabled by default + +Residual risk: + +- the dashboard is still intentionally simple and unauthenticated, so it should stay bound to trusted local or operator-only network surfaces + +### 2. Request / header / parameter injection + +Status: mitigated for the current request surface. + +- routing and operator headers are normalized and length-bounded before reaching traces, metrics, or rollout logic +- request hooks stay on a sanitized input/output surface +- oversized JSON bodies are rejected before route resolution +- oversized multipart uploads are rejected before provider calls +- provider failure details are logged internally but no longer echoed back to clients verbatim + +Residual risk: + +- upstream providers still define their own model- and payload-level validation semantics, so operators should keep provider-specific constraints tight in config + +### 3. Dependency vulnerabilities and unsafe defaults + +Status: reviewed against the current shipped surface. + +- the release CI covers Ruff, CodeQL, Python tests, packaging, and artifact checks +- the runtime keeps conservative defaults for cache control and response headers +- database output stays out of the repo checkout through `FOUNDRYGATE_DB_PATH` + +Residual risk: + +- dependency freshness remains an ongoing maintenance task, not a one-time release action + +### 4. Trust boundaries for local workers and upstream providers + +Status: tightened in config validation. + +- public/non-local provider URLs must now use `https` +- local or private-network workers may still use `http` +- `contract: local-worker` continues to require local/private network placement + +Residual risk: + +- FoundryGate is still a gateway, not an auth or service-mesh product; upstream TLS trust and network placement remain operator responsibilities + +### 5. Auth, secrets, and writable paths + +Status: documented and partially enforced. + +- provider secrets remain environment-driven +- writable state stays outside the repo by default +- repo safety checks continue to block common artifact and secret-adjacent file types + +Residual risk: + +- the runtime does not currently implement end-user auth; deployments should assume a trusted local or internal edge + +## Release Decision + +Result: acceptable for `v1.0.0`. + +The current review did not uncover a blocker that requires delaying the stable release, provided deployments keep the local-first trust model and conservative defaults intact. diff --git a/foundrygate/config.py b/foundrygate/config.py index c83acf5..eaa8f5d 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -178,6 +178,30 @@ def _looks_local_base_url(base_url: str) -> bool: return ip.is_loopback or ip.is_private or ip.is_link_local +def _validate_provider_base_url(name: str, base_url: str) -> str: + """Validate provider base URLs against the current trust-boundary baseline.""" + parsed = urlparse(base_url) + scheme = (parsed.scheme or "").strip().lower() + if scheme not in {"http", "https"}: + raise ConfigError( + "Provider " + f"'{name}' base_url must use http or https " + f"(got '{parsed.scheme or 'missing'}')" + ) + + if not parsed.netloc: + raise ConfigError(f"Provider '{name}' base_url must include a host") + + if scheme == "http" and not _looks_local_base_url(base_url): + raise ConfigError( + "Provider " + f"'{name}' base_url must use https unless it points " + "to local/private network space" + ) + + return base_url + + def _normalize_provider_capabilities(name: str, cfg: dict[str, Any]) -> dict[str, Any]: """Normalize and validate provider capability metadata.""" raw = cfg.get("capabilities") or {} @@ -386,6 +410,7 @@ def _normalize_provider(name: str, cfg: Any) -> dict[str, Any]: value = normalized.get(field, "") if not isinstance(value, str) or not value.strip(): raise ConfigError(f"Provider '{name}' must define a non-empty '{field}'") + normalized["base_url"] = _validate_provider_base_url(name, str(normalized["base_url"]).strip()) context_window = _normalize_positive_int( normalized.get("context_window"), diff --git a/foundrygate/main.py b/foundrygate/main.py index da3c221..245c460 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -12,7 +12,9 @@ import logging import re import time +from base64 import b64encode from contextlib import asynccontextmanager +from hashlib import sha256 from typing import Any from fastapi import FastAPI, Request @@ -93,6 +95,31 @@ def _sanitize_token(value: Any, *, default: str, max_chars: int | None = None) - return normalized or default +def _provider_error_category(status: int, detail: str) -> str: + """Return a coarse provider-error category without exposing upstream details.""" + if status == 0: + lowered = detail.lower() + if "timeout" in lowered: + return "timeout" + if "connection error" in lowered: + return "connection_error" + return "transport_error" + if 400 <= status < 500: + return "upstream_client_error" + if status >= 500: + return "upstream_server_error" + return "provider_error" + + +def _serialize_provider_attempt_error(provider_name: str, exc: ProviderError) -> dict[str, Any]: + """Return a sanitized provider-attempt failure object for client responses.""" + return { + "provider": provider_name, + "status": exc.status, + "category": _provider_error_category(exc.status, exc.detail), + } + + async def _refresh_local_worker_probes(force: bool = False) -> None: """Refresh local-worker health state when probes are due.""" timeout_seconds = float(_config.health.get("timeout_seconds", 10)) @@ -778,10 +805,7 @@ async def apply_security_headers(request: Request, call_next): if request.url.path == "/dashboard": response.headers.setdefault( "Content-Security-Policy", - "default-src 'self'; style-src 'self' 'unsafe-inline'; " - "script-src 'self' 'unsafe-inline'; img-src 'self' data:; " - "connect-src 'self'; object-src 'none'; base-uri 'none'; " - "frame-ancestors 'none'; form-action 'self'", + _dashboard_csp(), ) return response @@ -1131,7 +1155,7 @@ async def image_generations(request: Request): prompt = effective_body["prompt"].strip() image_fields = _collect_image_request_fields(effective_body) - errors: list[str] = [] + errors: list[dict[str, Any]] = [] for provider_name in attempt_order: provider = _providers.get(provider_name) @@ -1170,7 +1194,7 @@ async def image_generations(request: Request): resp.headers["X-FoundryGate-Hook-Errors"] = str(len(hook_state.errors)) return resp except ProviderError as exc: - errors.append(f"{provider_name}: {exc.detail}") + errors.append(_serialize_provider_attempt_error(provider_name, exc)) logger.warning( "Image provider %s failed: %s, trying next...", provider_name, @@ -1196,7 +1220,7 @@ async def image_generations(request: Request): return JSONResponse( { "error": { - "message": f"All image providers failed: {'; '.join(errors)}", + "message": "All image providers failed", "type": "provider_error", "attempts": errors, } @@ -1250,7 +1274,7 @@ async def image_edits(request: Request): return _invalid_request_response("Invalid image editing request", exc=exc) prompt = effective_body["prompt"].strip() - errors: list[str] = [] + errors: list[dict[str, Any]] = [] for provider_name in attempt_order: provider = _providers.get(provider_name) @@ -1294,7 +1318,7 @@ async def image_edits(request: Request): resp.headers["X-FoundryGate-Hook-Errors"] = str(len(hook_state.errors)) return resp except ProviderError as exc: - errors.append(f"{provider_name}: {exc.detail}") + errors.append(_serialize_provider_attempt_error(provider_name, exc)) logger.warning( "Image editing provider %s failed: %s, trying next...", provider_name, @@ -1320,7 +1344,7 @@ async def image_edits(request: Request): return JSONResponse( { "error": { - "message": f"All image editing providers failed: {'; '.join(errors)}", + "message": "All image editing providers failed", "type": "provider_error", "attempts": errors, } @@ -1382,7 +1406,7 @@ async def chat_completions(request: Request): # ── Execute with fallback ────────────────────────────── - errors: list[str] = [] + errors: list[dict[str, Any]] = [] for provider_name in attempt_order: provider = _providers.get(provider_name) @@ -1454,7 +1478,7 @@ async def chat_completions(request: Request): return resp except ProviderError as e: - errors.append(f"{provider_name}: {e.detail}") + errors.append(_serialize_provider_attempt_error(provider_name, e)) logger.warning("Provider %s failed: %s, trying next...", provider_name, e.detail[:200]) if _config.metrics.get("enabled"): _metrics.log_request( @@ -1478,7 +1502,7 @@ async def chat_completions(request: Request): return JSONResponse( { "error": { - "message": f"All providers failed: {'; '.join(errors)}", + "message": "All providers failed", "type": "provider_error", "attempts": errors, } @@ -1506,6 +1530,29 @@ def main(): # ── Dashboard HTML ───────────────────────────────────────────── + +def _inline_asset_hash(tag_name: str, html: str) -> str: + """Return the CSP hash token for one inline dashboard asset.""" + match = re.search(rf"<{tag_name}>(.*?)", html, re.DOTALL) + if not match: + return "" + digest = sha256(match.group(1).encode("utf-8")).digest() + return f"'sha256-{b64encode(digest).decode('ascii')}'" + + +def _dashboard_csp() -> str: + """Return the restrictive CSP for the built-in no-build dashboard.""" + style_hash = _inline_asset_hash("style", _DASHBOARD_HTML) + script_hash = _inline_asset_hash("script", _DASHBOARD_HTML) + return ( + "default-src 'self'; " + f"style-src 'self' {style_hash}; " + f"script-src 'self' {script_hash}; " + "img-src 'self' data:; connect-src 'self'; object-src 'none'; " + "base-uri 'none'; frame-ancestors 'none'; form-action 'self'" + ) + + _DASHBOARD_HTML = """ diff --git a/packages/foundrygate-cli/README.md b/packages/foundrygate-cli/README.md new file mode 100644 index 0000000..bebd2c1 --- /dev/null +++ b/packages/foundrygate-cli/README.md @@ -0,0 +1,32 @@ +# `@foundrygate/cli` + +Small npm CLI for checking and previewing a FoundryGate gateway. + +## Commands + +```bash +foundrygate-cli health +foundrygate-cli models +foundrygate-cli update --force +foundrygate-cli route --message "Route this request" --client codex +``` + +## Base URL + +Default: + +```bash +http://127.0.0.1:8090 +``` + +Override with: + +```bash +FOUNDRYGATE_BASE_URL=http://127.0.0.1:8090 +``` + +or: + +```bash +foundrygate-cli health --base-url http://127.0.0.1:8090 +``` diff --git a/packages/foundrygate-cli/bin/foundrygate.js b/packages/foundrygate-cli/bin/foundrygate.js new file mode 100755 index 0000000..5d29c8a --- /dev/null +++ b/packages/foundrygate-cli/bin/foundrygate.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node + +const args = process.argv.slice(2); + +function usage() { + console.log(`FoundryGate CLI + +Usage: + foundrygate-cli health [--base-url URL] + foundrygate-cli models [--base-url URL] + foundrygate-cli update [--base-url URL] [--force] + foundrygate-cli route --message TEXT [--base-url URL] [--model MODEL] [--client TAG] + +Environment: + FOUNDRYGATE_BASE_URL Override the gateway base URL (default: http://127.0.0.1:8090) +`); +} + +function readOption(name, fallback = "") { + const index = args.indexOf(name); + if (index === -1 || index === args.length - 1) return fallback; + return args[index + 1]; +} + +function hasFlag(name) { + return args.includes(name); +} + +function commandName() { + return args[0] || ""; +} + +function baseUrl() { + const raw = readOption("--base-url", process.env.FOUNDRYGATE_BASE_URL || "http://127.0.0.1:8090"); + return raw.replace(/\/+$/, ""); +} + +async function requestJson(path, options = {}) { + const response = await fetch(`${baseUrl()}${path}`, options); + const text = await response.text(); + let body; + try { + body = JSON.parse(text); + } catch { + body = { raw: text }; + } + if (!response.ok) { + const error = new Error(`Request failed with HTTP ${response.status}`); + error.response = body; + throw error; + } + return body; +} + +async function run() { + const command = commandName(); + if (!command || hasFlag("--help") || hasFlag("-h")) { + usage(); + return; + } + + if (command === "health") { + console.log(JSON.stringify(await requestJson("/health"), null, 2)); + return; + } + + if (command === "models") { + console.log(JSON.stringify(await requestJson("/v1/models"), null, 2)); + return; + } + + if (command === "update") { + const suffix = hasFlag("--force") ? "?force=true" : ""; + console.log(JSON.stringify(await requestJson(`/api/update${suffix}`), null, 2)); + return; + } + + if (command === "route") { + const message = readOption("--message"); + if (!message) { + throw new Error("route requires --message"); + } + const model = readOption("--model", "auto"); + const clientTag = readOption("--client", ""); + const headers = { "content-type": "application/json" }; + if (clientTag) { + headers["x-foundrygate-client"] = clientTag; + } + const body = { + model, + messages: [{ role: "user", content: message }] + }; + console.log( + JSON.stringify( + await requestJson("/api/route", { + method: "POST", + headers, + body: JSON.stringify(body) + }), + null, + 2 + ) + ); + return; + } + + throw new Error(`Unknown command '${command}'`); +} + +run().catch((error) => { + console.error(error.message); + if (error.response) { + console.error(JSON.stringify(error.response, null, 2)); + } + process.exit(1); +}); diff --git a/packages/foundrygate-cli/package.json b/packages/foundrygate-cli/package.json new file mode 100644 index 0000000..92b66e6 --- /dev/null +++ b/packages/foundrygate-cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@foundrygate/cli", + "version": "1.0.0", + "description": "Small npm CLI for checking and previewing a FoundryGate gateway.", + "license": "Apache-2.0", + "type": "module", + "files": [ + "bin", + "README.md" + ], + "bin": { + "foundrygate-cli": "./bin/foundrygate.js" + }, + "engines": { + "node": ">=18" + }, + "keywords": [ + "foundrygate", + "cli", + "openai-compatible", + "gateway", + "router" + ], + "homepage": "https://github.com/typelicious/FoundryGate", + "repository": { + "type": "git", + "url": "git+https://github.com/typelicious/FoundryGate.git", + "directory": "packages/foundrygate-cli" + } +} diff --git a/tests/test_api_hardening.py b/tests/test_api_hardening.py index a7e3b21..fdbafb2 100644 --- a/tests/test_api_hardening.py +++ b/tests/test_api_hardening.py @@ -78,6 +78,15 @@ async def complete(self, *_args, **_kwargs): } +class _FailingProviderStub(_ProviderStub): + async def complete(self, *_args, **_kwargs): + raise main_module.ProviderError( + "cloud-default", + 502, + "upstream trace: token=secret should not leak", + ) + + class _MetricsStub: def log_request(self, **_kwargs): return None @@ -168,7 +177,10 @@ def test_dashboard_sets_security_headers(api_client): assert response.headers["x-content-type-options"] == "nosniff" assert response.headers["x-frame-options"] == "DENY" assert response.headers["referrer-policy"] == "no-referrer" - assert "frame-ancestors 'none'" in response.headers["content-security-policy"] + csp = response.headers["content-security-policy"] + assert "frame-ancestors 'none'" in csp + assert "'unsafe-inline'" not in csp + assert "sha256-" in csp def test_route_preview_rejects_large_json_payload(api_client): @@ -224,3 +236,28 @@ def test_chat_completions_returns_security_headers(api_client): assert response.headers["x-foundrygate-provider"] == "cloud-default" assert response.headers["cache-control"] == "no-store" assert response.headers["x-content-type-options"] == "nosniff" + + +def test_chat_completions_hides_upstream_provider_details(api_client, monkeypatch): + monkeypatch.setattr( + main_module, + "_providers", + {"cloud-default": _FailingProviderStub()}, + raising=False, + ) + + response = api_client.post( + "/v1/chat/completions", + json={ + "model": "auto", + "messages": [{"role": "user", "content": "cause failure"}], + }, + ) + + assert response.status_code == 502 + body = response.json() + assert body["error"]["message"] == "All providers failed" + assert "secret" not in response.text + assert body["error"]["attempts"] == [ + {"provider": "cloud-default", "status": 502, "category": "upstream_server_error"} + ] diff --git a/tests/test_config.py b/tests/test_config.py index ddcf254..def29d1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -150,3 +150,49 @@ def test_security_rejects_invalid_limit_values(tmp_path): with pytest.raises(ConfigError, match="security.max_json_body_bytes"): load_config(path) + + +def test_provider_rejects_public_http_base_url(tmp_path): + path = tmp_path / "config.yaml" + path.write_text( + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + cloud-default: + backend: openai-compat + base_url: "http://api.example.com/v1" + api_key: "secret" + model: "chat-model" +fallback_chain: [] +metrics: + enabled: false +""" + ) + + with pytest.raises(ConfigError, match="must use https"): + load_config(path) + + +def test_provider_allows_local_http_base_url(tmp_path): + path = tmp_path / "config.yaml" + path.write_text( + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + local-worker: + backend: openai-compat + base_url: "http://127.0.0.1:11434/v1" + api_key: "local" + model: "llama3" +fallback_chain: [] +metrics: + enabled: false +""" + ) + + cfg = load_config(path) + assert cfg.providers["local-worker"]["base_url"] == "http://127.0.0.1:11434/v1"