Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe29379ad2 | ||
|
|
9a641ed4a5 |
@@ -1,59 +0,0 @@
|
|||||||
# Web UI deployment boundary (#435)
|
|
||||||
|
|
||||||
The MCP Control Plane web UI is an **internal operator console**, not a
|
|
||||||
customer-facing application. The MVP assumes local or trusted-network access
|
|
||||||
only.
|
|
||||||
|
|
||||||
## MVP deployment model
|
|
||||||
|
|
||||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
|
||||||
- **Authentication:** none in MVP — protection comes from network placement
|
|
||||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
|
||||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
|
||||||
embedded in HTML, JavaScript, or browser storage
|
|
||||||
|
|
||||||
Do **not** expose the UI on the public internet without an access layer.
|
|
||||||
|
|
||||||
## Beyond localhost
|
|
||||||
|
|
||||||
If the UI must be reachable outside the operator laptop:
|
|
||||||
|
|
||||||
1. Prefer **Cloudflare Access**, **Cloudflare WARP**, or an org **VPN** so only
|
|
||||||
authenticated staff reach the service.
|
|
||||||
2. Bind to a specific interface only when necessary — never `0.0.0.0` / `::`
|
|
||||||
without understanding the exposure.
|
|
||||||
3. Set explicit override env vars only after access controls are in place:
|
|
||||||
- `WEBUI_ALLOW_PUBLIC_BIND=1` — acknowledges all-interface bind (`0.0.0.0`, `::`)
|
|
||||||
- `WEBUI_ALLOW_REMOTE_BIND=1` — acknowledges a non-loopback host
|
|
||||||
|
|
||||||
Startup **refuses** all-interface binds unless `WEBUI_ALLOW_PUBLIC_BIND=1`.
|
|
||||||
Non-loopback binds log a warning unless `WEBUI_ALLOW_REMOTE_BIND=1`.
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
|
||||||
|----------|---------|---------|
|
|
||||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address |
|
|
||||||
| `WEBUI_PORT` | `8765` | Listen port |
|
|
||||||
| `WEBUI_REPO_ROOT` | repository root | Workflow/schema hash root for prompt library |
|
|
||||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry file path |
|
|
||||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config path (optional) |
|
|
||||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (optional) |
|
|
||||||
| `WEBUI_ALLOW_PUBLIC_BIND` | unset | Acknowledge `0.0.0.0` / `::` bind |
|
|
||||||
| `WEBUI_ALLOW_REMOTE_BIND` | unset | Acknowledge non-loopback bind |
|
|
||||||
|
|
||||||
Gitea credentials (`GITEA_TOKEN_*`, `.env.*`, keychain refs) are read only on
|
|
||||||
the server when a page needs live Gitea data (e.g. `/queue`). They are not
|
|
||||||
shipped to the browser.
|
|
||||||
|
|
||||||
## Health / deployment metadata
|
|
||||||
|
|
||||||
`GET /health` includes a `deployment` object with bind disposition, runtime
|
|
||||||
assumption paths, and the client-secret policy. Use it to verify an instance is
|
|
||||||
configured for internal-only operation.
|
|
||||||
|
|
||||||
## Non-goals (MVP)
|
|
||||||
|
|
||||||
- Full SSO or session login in the UI
|
|
||||||
- Hosting on the public internet without Access/VPN/WARP
|
|
||||||
- Embedding Gitea tokens in the frontend bundle
|
|
||||||
@@ -29,13 +29,6 @@ Optional environment variables:
|
|||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
|
||||||
| `WEBUI_PORT` | `8765` | Listen port |
|
| `WEBUI_PORT` | `8765` | Listen port |
|
||||||
| `WEBUI_REPO_ROOT` | repository root | Prompt library workflow hash root |
|
|
||||||
| `WEBUI_PROJECT_REGISTRY` | packaged JSON | Project registry path |
|
|
||||||
| `GITEA_MCP_CONFIG` | unset | Server-side MCP profile config (never sent to browser) |
|
|
||||||
| `GITEA_MCP_PROFILE` | unset | Active MCP profile name (server-side only) |
|
|
||||||
|
|
||||||
See [webui-deployment.md](webui-deployment.md) for internal-only serving,
|
|
||||||
Cloudflare Access/WARP/VPN guidance, and unsafe bind overrides (#435).
|
|
||||||
|
|
||||||
## Routes (MVP)
|
## Routes (MVP)
|
||||||
|
|
||||||
@@ -141,12 +134,6 @@ health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
|
|||||||
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
checkout is behind merged safety-gate changes. Restart guidance links to #420;
|
||||||
no tokens or MCP restart actions are exposed.
|
no tokens or MCP restart actions are exposed.
|
||||||
|
|
||||||
## Deployment boundary (#435)
|
|
||||||
|
|
||||||
MVP serves on loopback by default. Binding `0.0.0.0` or `::` is **refused**
|
|
||||||
unless `WEBUI_ALLOW_PUBLIC_BIND=1`. Non-loopback hosts log a warning unless
|
|
||||||
`WEBUI_ALLOW_REMOTE_BIND=1`. `GET /health` exposes `deployment` metadata.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -154,8 +141,6 @@ pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/t
|
|||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_audit.py tests/test_webui_worktree_hygiene.py -q
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
|
||||||
|
|
||||||
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_deployment_boundary.py -q
|
|
||||||
|
|
||||||
## Tests (#436)
|
## Tests (#436)
|
||||||
|
|
||||||
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
Run the full hermetic web UI suite (all `test_webui_*.py` modules):
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
"""Tests for web UI deployment and auth boundary (#435)."""
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from starlette.testclient import TestClient
|
|
||||||
|
|
||||||
from webui.app import create_app
|
|
||||||
from webui.deployment_boundary import (
|
|
||||||
assess_bind_host,
|
|
||||||
runtime_assumptions,
|
|
||||||
scan_text_for_client_secrets,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBindAssessment(unittest.TestCase):
|
|
||||||
def test_loopback_is_safe(self):
|
|
||||||
for host in ("127.0.0.1", "localhost", "::1"):
|
|
||||||
with self.subTest(host=host):
|
|
||||||
result = assess_bind_host(host)
|
|
||||||
self.assertEqual(result.disposition, "safe")
|
|
||||||
|
|
||||||
def test_all_interfaces_refused_by_default(self):
|
|
||||||
for host in ("0.0.0.0", "::", "*"):
|
|
||||||
with self.subTest(host=host):
|
|
||||||
result = assess_bind_host(host)
|
|
||||||
self.assertEqual(result.disposition, "refuse")
|
|
||||||
|
|
||||||
def test_all_interfaces_allowed_with_override(self):
|
|
||||||
prior = os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
|
||||||
try:
|
|
||||||
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = "1"
|
|
||||||
result = assess_bind_host("0.0.0.0")
|
|
||||||
self.assertEqual(result.disposition, "warn")
|
|
||||||
finally:
|
|
||||||
os.environ.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
|
||||||
if prior is not None:
|
|
||||||
os.environ["WEBUI_ALLOW_PUBLIC_BIND"] = prior
|
|
||||||
|
|
||||||
def test_non_loopback_warns_without_override(self):
|
|
||||||
prior = os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
|
|
||||||
try:
|
|
||||||
os.environ.pop("WEBUI_ALLOW_REMOTE_BIND", None)
|
|
||||||
result = assess_bind_host("192.168.1.10")
|
|
||||||
self.assertEqual(result.disposition, "warn")
|
|
||||||
finally:
|
|
||||||
if prior is not None:
|
|
||||||
os.environ["WEBUI_ALLOW_REMOTE_BIND"] = prior
|
|
||||||
|
|
||||||
def test_runtime_assumptions_exclude_secrets(self):
|
|
||||||
assumptions = runtime_assumptions()
|
|
||||||
blob = " ".join(str(v) for v in assumptions.values())
|
|
||||||
self.assertNotIn("GITEA_TOKEN", blob)
|
|
||||||
self.assertIn("internal-operator-console", assumptions["deployment_mode"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeploymentRoutes(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = TestClient(create_app(bind_host="127.0.0.1"))
|
|
||||||
|
|
||||||
def test_health_includes_deployment_metadata(self):
|
|
||||||
response = self.client.get("/health")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
data = response.json()
|
|
||||||
self.assertIn("deployment", data)
|
|
||||||
deployment = data["deployment"]
|
|
||||||
self.assertEqual(deployment["mode"], "internal-operator-console")
|
|
||||||
self.assertEqual(deployment["mvp_auth"], "none")
|
|
||||||
self.assertEqual(deployment["bind"]["disposition"], "safe")
|
|
||||||
self.assertIn("runtime_assumptions", deployment)
|
|
||||||
|
|
||||||
def test_rendered_pages_contain_no_embedded_secrets(self):
|
|
||||||
for path in ("/", "/projects", "/prompts", "/queue"):
|
|
||||||
with self.subTest(path=path):
|
|
||||||
text = self.client.get(path).text
|
|
||||||
self.assertEqual(scan_text_for_client_secrets(text), [])
|
|
||||||
|
|
||||||
|
|
||||||
class TestStartupRefusal(unittest.TestCase):
|
|
||||||
def test_refuses_all_interface_bind(self):
|
|
||||||
env = {**os.environ, "WEBUI_HOST": "0.0.0.0"}
|
|
||||||
env.pop("WEBUI_ALLOW_PUBLIC_BIND", None)
|
|
||||||
repo_root = Path(__file__).resolve().parent.parent
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "webui"],
|
|
||||||
cwd=repo_root,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=3,
|
|
||||||
)
|
|
||||||
self.assertEqual(result.returncode, 1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestClientSecretScanner(unittest.TestCase):
|
|
||||||
def test_detects_token_like_content(self):
|
|
||||||
findings = scan_text_for_client_secrets("var x = GITEA_TOKEN_PRGS")
|
|
||||||
self.assertTrue(findings)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -22,8 +22,6 @@ class TestWebuiSkeleton(unittest.TestCase):
|
|||||||
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
self.assertEqual(data["service"], "mcp-control-plane-webui")
|
||||||
self.assertEqual(data["mode"], "read-only-mvp")
|
self.assertEqual(data["mode"], "read-only-mvp")
|
||||||
self.assertIn("timestamp", data)
|
self.assertIn("timestamp", data)
|
||||||
self.assertIn("deployment", data)
|
|
||||||
self.assertEqual(data["deployment"]["mode"], "internal-operator-console")
|
|
||||||
|
|
||||||
def test_home_renders(self):
|
def test_home_renders(self):
|
||||||
response = self.client.get("/")
|
response = self.client.get("/")
|
||||||
|
|||||||
+1
-18
@@ -2,33 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from webui.deployment_boundary import assess_bind_host
|
from webui.app import create_app
|
||||||
|
|
||||||
logger = logging.getLogger("webui")
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_bind_host(host: str) -> None:
|
|
||||||
assessment = assess_bind_host(host)
|
|
||||||
if assessment.disposition == "refuse":
|
|
||||||
logger.error("webui bind refused: %s", assessment.message)
|
|
||||||
raise SystemExit(1)
|
|
||||||
if assessment.disposition == "warn":
|
|
||||||
logger.warning("webui bind warning: %s", assessment.message)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
|
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
|
||||||
port = int(os.environ.get("WEBUI_PORT", "8765"))
|
port = int(os.environ.get("WEBUI_PORT", "8765"))
|
||||||
# Validate bind host before importing the full app stack so refuse paths
|
|
||||||
# fail closed quickly (tests and operators must not hang on create_app).
|
|
||||||
_validate_bind_host(host)
|
|
||||||
from webui.app import create_app
|
|
||||||
|
|
||||||
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
uvicorn.run(create_app(), host=host, port=port, log_level="info")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-10
@@ -9,7 +9,6 @@ from starlette.requests import Request
|
|||||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
from webui.deployment_boundary import deployment_snapshot
|
|
||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
from webui.project_registry import find_project, load_registry, registry_to_dict
|
||||||
from webui.project_views import render_project_detail, render_projects_list
|
from webui.project_views import render_project_detail, render_projects_list
|
||||||
@@ -62,13 +61,11 @@ async def home(_request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def health(_request: Request) -> JSONResponse:
|
async def health(_request: Request) -> JSONResponse:
|
||||||
bind_host = _request.app.state.webui_bind_host
|
|
||||||
return JSONResponse({
|
return JSONResponse({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "mcp-control-plane-webui",
|
"service": "mcp-control-plane-webui",
|
||||||
"mode": "read-only-mvp",
|
"mode": "read-only-mvp",
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"deployment": deployment_snapshot(bind_host=bind_host),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -253,12 +250,9 @@ async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
|||||||
return JSONResponse({"error": "not_found"}, status_code=404)
|
return JSONResponse({"error": "not_found"}, status_code=404)
|
||||||
|
|
||||||
|
|
||||||
def create_app(*, bind_host: str | None = None) -> Starlette:
|
def create_app() -> Starlette:
|
||||||
"""Build the read-only MVP Starlette app."""
|
"""Build the read-only MVP Starlette app."""
|
||||||
import os
|
return Starlette(
|
||||||
|
|
||||||
resolved_bind = bind_host or os.environ.get("WEBUI_HOST", "127.0.0.1")
|
|
||||||
app = Starlette(
|
|
||||||
debug=False,
|
debug=False,
|
||||||
routes=[
|
routes=[
|
||||||
Route("/", home, methods=["GET"]),
|
Route("/", home, methods=["GET"]),
|
||||||
@@ -294,5 +288,3 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
|||||||
],
|
],
|
||||||
exception_handlers={405: method_not_allowed},
|
exception_handlers={405: method_not_allowed},
|
||||||
)
|
)
|
||||||
app.state.webui_bind_host = resolved_bind
|
|
||||||
return app
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
"""Internal-only deployment boundary for the operator web UI (#435)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
Disposition = Literal["safe", "warn", "refuse"]
|
|
||||||
|
|
||||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
||||||
_ALL_INTERFACE_HOSTS = frozenset({"0.0.0.0", "::", "*"})
|
|
||||||
|
|
||||||
_ALLOW_PUBLIC_BIND_ENV = "WEBUI_ALLOW_PUBLIC_BIND"
|
|
||||||
_ALLOW_REMOTE_BIND_ENV = "WEBUI_ALLOW_REMOTE_BIND"
|
|
||||||
|
|
||||||
_FORBIDDEN_CLIENT_PATTERNS = (
|
|
||||||
re.compile(r"GITEA_(?:TOKEN|PASS|PASSWORD)", re.I),
|
|
||||||
re.compile(r"Bearer\s+[A-Za-z0-9._\-]{20,}"),
|
|
||||||
re.compile(r"password\s*[:=]\s*['\"][^'\"]+['\"]", re.I),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class BindAssessment:
|
|
||||||
host: str
|
|
||||||
disposition: Disposition
|
|
||||||
message: str
|
|
||||||
override_env: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _truthy_env(name: str) -> bool:
|
|
||||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
|
|
||||||
|
|
||||||
|
|
||||||
def assess_bind_host(host: str) -> BindAssessment:
|
|
||||||
"""Classify a bind host as safe, warn, or refuse (fail closed on 0.0.0.0/::)."""
|
|
||||||
normalized = (host or "").strip().lower()
|
|
||||||
if not normalized:
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="refuse",
|
|
||||||
message="WEBUI_HOST must not be empty.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized in _LOOPBACK_HOSTS:
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="safe",
|
|
||||||
message="Loopback bind — suitable for local operator console.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if normalized in _ALL_INTERFACE_HOSTS:
|
|
||||||
if _truthy_env(_ALLOW_PUBLIC_BIND_ENV):
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="warn",
|
|
||||||
message=(
|
|
||||||
"Binding all interfaces. Protect with Cloudflare Access, "
|
|
||||||
"WARP, VPN, or equivalent before exposing beyond localhost."
|
|
||||||
),
|
|
||||||
override_env=_ALLOW_PUBLIC_BIND_ENV,
|
|
||||||
)
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="refuse",
|
|
||||||
message=(
|
|
||||||
f"Refusing all-interface bind ({host!r}). Set "
|
|
||||||
f"{_ALLOW_PUBLIC_BIND_ENV}=1 only when fronted by trusted "
|
|
||||||
"network access controls."
|
|
||||||
),
|
|
||||||
override_env=_ALLOW_PUBLIC_BIND_ENV,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
addr = ipaddress.ip_address(normalized)
|
|
||||||
if addr.is_loopback:
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="safe",
|
|
||||||
message="Loopback bind — suitable for local operator console.",
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if _truthy_env(_ALLOW_REMOTE_BIND_ENV):
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="warn",
|
|
||||||
message=(
|
|
||||||
"Non-loopback bind acknowledged. Restrict to a trusted network "
|
|
||||||
"and add Cloudflare Access, WARP, or VPN if reachable beyond it."
|
|
||||||
),
|
|
||||||
override_env=_ALLOW_REMOTE_BIND_ENV,
|
|
||||||
)
|
|
||||||
|
|
||||||
return BindAssessment(
|
|
||||||
host=host,
|
|
||||||
disposition="warn",
|
|
||||||
message=(
|
|
||||||
f"Non-loopback bind ({host!r}). MVP expects 127.0.0.1; set "
|
|
||||||
f"{_ALLOW_REMOTE_BIND_ENV}=1 to acknowledge a trusted-network bind."
|
|
||||||
),
|
|
||||||
override_env=_ALLOW_REMOTE_BIND_ENV,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_assumptions() -> dict[str, str]:
|
|
||||||
"""Documented runtime paths and hosts — never includes secrets."""
|
|
||||||
repo_root = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
|
||||||
registry = (os.environ.get("WEBUI_PROJECT_REGISTRY") or "").strip()
|
|
||||||
profile_config = (os.environ.get("GITEA_MCP_CONFIG") or "").strip()
|
|
||||||
profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip()
|
|
||||||
return {
|
|
||||||
"deployment_mode": "internal-operator-console",
|
|
||||||
"webui_host_env": "WEBUI_HOST",
|
|
||||||
"webui_port_env": "WEBUI_PORT",
|
|
||||||
"webui_repo_root_env": "WEBUI_REPO_ROOT",
|
|
||||||
"webui_repo_root": repo_root or "(defaults to repository root)",
|
|
||||||
"webui_project_registry_env": "WEBUI_PROJECT_REGISTRY",
|
|
||||||
"webui_project_registry": registry or "(packaged webui/data/projects.registry.json)",
|
|
||||||
"gitea_mcp_config_env": "GITEA_MCP_CONFIG",
|
|
||||||
"gitea_mcp_config": profile_config or "(optional; server-side only)",
|
|
||||||
"gitea_mcp_profile_env": "GITEA_MCP_PROFILE",
|
|
||||||
"gitea_mcp_profile": profile_name or "(optional; server-side only)",
|
|
||||||
"gitea_credentials": "Resolved server-side via gitea_auth; never embedded in HTML/JS",
|
|
||||||
"public_bind_override_env": _ALLOW_PUBLIC_BIND_ENV,
|
|
||||||
"remote_bind_override_env": _ALLOW_REMOTE_BIND_ENV,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def scan_text_for_client_secrets(text: str) -> list[str]:
|
|
||||||
"""Return human-readable findings if *text* looks like it embeds secrets."""
|
|
||||||
findings: list[str] = []
|
|
||||||
for pattern in _FORBIDDEN_CLIENT_PATTERNS:
|
|
||||||
if pattern.search(text):
|
|
||||||
findings.append(f"matched forbidden client pattern: {pattern.pattern}")
|
|
||||||
return findings
|
|
||||||
|
|
||||||
|
|
||||||
def deployment_snapshot(*, bind_host: str | None = None) -> dict[str, object]:
|
|
||||||
host = bind_host if bind_host is not None else os.environ.get("WEBUI_HOST", "127.0.0.1")
|
|
||||||
bind = assess_bind_host(host)
|
|
||||||
return {
|
|
||||||
"mode": "internal-operator-console",
|
|
||||||
"mvp_auth": "none",
|
|
||||||
"bind": asdict(bind),
|
|
||||||
"runtime_assumptions": runtime_assumptions(),
|
|
||||||
"client_secret_policy": (
|
|
||||||
"No Gitea tokens, passwords, or profile secrets in HTML, JS, "
|
|
||||||
"or browser storage."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user