diff --git a/python/packages/devui/AGENTS.md b/python/packages/devui/AGENTS.md index 5213095244..a3febee047 100644 --- a/python/packages/devui/AGENTS.md +++ b/python/packages/devui/AGENTS.md @@ -34,6 +34,12 @@ devui ./agents devui --entities my_agent.py ``` +## Security Posture + +DevUI is a development-only sample app, not a production hosting surface. Authentication is enabled by default. +Unauthenticated mode is allowed only on `localhost` / `127.0.0.1`; `0.0.0.0`, LAN IPs, and hostnames require +`DEVUI_AUTH_TOKEN` or `--auth-token`. + ## Import Path ```python diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index 669e7cd4d4..f5acf2dad6 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -47,6 +47,9 @@ devui ./agents --port 8080 # → API: http://localhost:8080/v1/* ``` +DevUI is auth-enabled by default. Localhost starts with a generated development token logged at startup; pass it as +`Authorization: Bearer ` for direct API calls. + When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly. ## Using MCP Tools @@ -137,12 +140,14 @@ For convenience, DevUI provides an OpenAI Responses backend API. This means you ```bash # Simple - use your entity name as the entity_id in metadata curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d @- << 'EOF' { "metadata": {"entity_id": "weather_agent"}, "input": "Hello world" } +EOF ``` Or use the OpenAI Python SDK: @@ -152,7 +157,7 @@ from openai import OpenAI client = OpenAI( base_url="http://localhost:8080/v1", - api_key="not-needed" # API key not required for local DevUI + api_key="" ) response = client.responses.create( @@ -201,6 +206,7 @@ DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly th ```bash curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "X-Proxy-Backend: openai" \ -d '{"model": "gpt-4.1-mini", "input": "Hello"}' ``` @@ -214,14 +220,14 @@ devui [directory] [options] Options: --port, -p Port (default: 8080) - --host Host (default: 127.0.0.1) + --host Host (default: 127.0.0.1; non-loopback hosts require auth) --headless API only, no UI --no-open Don't automatically open browser --instrumentation Enable OpenTelemetry instrumentation --reload Enable auto-reload --mode developer|user (default: developer) - --auth Enable Bearer token authentication - --auth-token Custom authentication token + --no-auth Disable auth for loopback-only local development + --auth-token Custom authentication token (required for non-loopback hosts unless DEVUI_AUTH_TOKEN is set) ``` ### UI Modes @@ -233,8 +239,8 @@ Options: # Development devui ./agents -# Production (user-facing) -devui ./agents --mode user --auth +# Local-only no-auth development +devui ./agents --no-auth ``` ## Key Endpoints @@ -336,28 +342,39 @@ These custom extensions are clearly namespaced and can be safely ignored by stan ## Security -DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks without proper authentication. +DevUI is designed as a **sample application for local development** and is not intended for production use. For +production, or for features beyond this sample app, build a custom interface and API server using the Agent Framework SDK. -**For production deployments:** +Auth is enabled by default. Unauthenticated mode is allowed only when DevUI is bound to `localhost` or `127.0.0.1`. +Network-reachable binds such as `0.0.0.0`, LAN IPs, and hostnames require Bearer token authentication with an explicit +token. + +**For shared development hosts:** ```bash -# User mode with authentication (recommended) -devui ./agents --mode user --auth --host 0.0.0.0 +# Set a token explicitly before binding beyond loopback +DEVUI_AUTH_TOKEN="" devui ./agents --mode user --host 0.0.0.0 + +# Or pass the token on the command line +devui ./agents --mode user --host 0.0.0.0 --auth-token "" ``` -This restricts developer APIs (reload, deployment, entity details) and requires Bearer token authentication. +Do not use `--no-auth` with `0.0.0.0`, LAN IPs, or hostnames. That configuration fails closed before startup. **Security features:** - User mode restricts developer-facing APIs -- Optional Bearer token authentication via `--auth` +- Bearer token authentication is enabled by default +- Unauthenticated mode is loopback-only (`localhost` / `127.0.0.1`) +- Non-loopback binds require `DEVUI_AUTH_TOKEN` or `--auth-token` - Only loads entities from local directories or in-memory registration - No remote code execution capabilities - Binds to localhost (127.0.0.1) by default **Best practices:** -- Use `--mode user --auth` for any deployment exposed to end users +- Do not use DevUI as a production deployment surface +- Use `--mode user` plus `DEVUI_AUTH_TOKEN` or `--auth-token` for shared development hosts - Review all agent/workflow code before running - Only load entities from trusted sources - Use `.env` files for sensitive credentials (never commit them) diff --git a/python/packages/devui/agent_framework_devui/__init__.py b/python/packages/devui/agent_framework_devui/__init__.py index b647c60fed..470134cb09 100644 --- a/python/packages/devui/agent_framework_devui/__init__.py +++ b/python/packages/devui/agent_framework_devui/__init__.py @@ -126,29 +126,6 @@ def serve( if not isinstance(port, int) or not (1 <= port <= 65535): raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535") - # Security check: warn loudly when network-exposed without authentication. - if host not in ("127.0.0.1", "localhost") and not auth_enabled: - logger.warning("WARNING: Exposing DevUI to the network with --no-auth.") - logger.warning("Anyone on your network can read agent metadata and trigger requests.") - logger.warning("Drop --no-auth and DevUI will require Bearer tokens.") - - # Refuse to auto-generate a token for network-exposed binds. Auto-generated tokens - # are fine for localhost convenience; for anything else, require an explicit token. - if auth_enabled and not auth_token: - import os - - env_token = os.environ.get("DEVUI_AUTH_TOKEN") - if not env_token: - is_production = ( - host not in ("127.0.0.1", "localhost") - or os.environ.get("CI") == "true" - or os.environ.get("KUBERNETES_SERVICE_HOST") - ) - if is_production: - logger.error("Authentication required but no token provided.") - logger.error("Set DEVUI_AUTH_TOKEN env var or pass auth_token='...' to serve().") - raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost") - # Enable instrumentation if requested if instrumentation_enabled: from agent_framework.observability import enable_instrumentation diff --git a/python/packages/devui/agent_framework_devui/_cli.py b/python/packages/devui/agent_framework_devui/_cli.py index e5e64b6fd4..209b982cba 100644 --- a/python/packages/devui/agent_framework_devui/_cli.py +++ b/python/packages/devui/agent_framework_devui/_cli.py @@ -81,13 +81,15 @@ Examples: parser.add_argument( "--no-auth", action="store_true", - help="Disable Bearer token authentication. DevUI is auth-enabled by default; use this to opt out.", + help=( + "Disable Bearer token authentication for loopback-only local development. Non-loopback hosts require auth." + ), ) parser.add_argument( "--auth-token", type=str, - help="Custom Bearer token. Auto-generated and logged at startup when omitted.", + help="Custom Bearer token. Required for non-loopback hosts when DEVUI_AUTH_TOKEN is not set.", ) parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}") diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 416821f40e..08c454f94e 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -89,7 +89,7 @@ class DevServer: mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors) auth_enabled: Whether to require Bearer token auth on /v1/* endpoints. Defaults to True. auth_token: Bearer token. If None and auth_enabled, falls back to the DEVUI_AUTH_TOKEN - environment variable, then to an auto-generated token (logged at startup). + environment variable. Loopback binds may use an auto-generated token logged at startup. """ self.entities_dir = entities_dir self.port = port @@ -106,7 +106,7 @@ class DevServer: self.ui_enabled = ui_enabled self.mode = mode self.auth_enabled = auth_enabled - self.auth_token = self._resolve_auth_token(auth_enabled, auth_token) + self.auth_token = self._resolve_auth_token(host, auth_enabled, auth_token) self.executor: AgentFrameworkExecutor | None = None self.openai_executor: OpenAIExecutor | None = None self.deployment_manager = DeploymentManager() @@ -118,8 +118,14 @@ class DevServer: """Set in-memory entities to register on startup.""" self._pending_entities = entities + _AUTH_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost"}) _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]", "::1"}) + @classmethod + def _is_auth_loopback_host(cls, host: str) -> bool: + """Return True when unauthenticated DevUI may be limited to local loopback.""" + return host.lower() in cls._AUTH_LOOPBACK_HOSTS + def _loopback_allowed_hosts(self) -> frozenset[str] | None: """Return the Host-header allowlist when bound to a loopback interface, else None. @@ -131,16 +137,25 @@ class DevServer: return None return self._LOOPBACK_HOSTS - @staticmethod - def _resolve_auth_token(auth_enabled: bool, auth_token: str | None) -> str | None: + @classmethod + def _resolve_auth_token(cls, host: str, auth_enabled: bool, auth_token: str | None) -> str | None: """Resolve the active Bearer token. Returns None when auth is disabled.""" + is_loopback = cls._is_auth_loopback_host(host) if not auth_enabled: + if not is_loopback: + raise ValueError( + "DevUI authentication cannot be disabled for non-loopback hosts. " + "Bind to 127.0.0.1/localhost for no-auth local development, or enable auth and provide " + "DEVUI_AUTH_TOKEN or auth_token for network-reachable binds." + ) return None if auth_token: return auth_token env_token = os.getenv("DEVUI_AUTH_TOKEN") if env_token: return env_token + if not is_loopback: + raise ValueError("DEVUI_AUTH_TOKEN or auth_token is required when DevUI is bound to a non-loopback host.") generated = secrets.token_urlsafe(32) logger.info("=" * 70) logger.info("DevUI authentication enabled with auto-generated token:") diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index d537c22ca7..0f7441b9bd 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -60,6 +60,9 @@ devui This launches the UI with all example agents/workflows at http://localhost:8080 +DevUI is auth-enabled by default. Copy the generated token from startup logs and pass it as +`Authorization: Bearer ` for direct API calls. Use `--no-auth` only for loopback-only local testing. + ## 5. What You'll See - A web interface for testing agents interactively @@ -74,6 +77,7 @@ You can also test via API calls: ```bash curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", @@ -86,6 +90,7 @@ curl -X POST http://localhost:8080/v1/responses \ ```bash # Create a conversation curl -X POST http://localhost:8080/v1/conversations \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"metadata": {"agent_id": "weather_agent"}}' @@ -93,6 +98,7 @@ curl -X POST http://localhost:8080/v1/conversations \ # Use conversation ID in requests curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", @@ -102,6 +108,7 @@ curl -X POST http://localhost:8080/v1/responses \ # Continue the conversation curl -X POST http://localhost:8080/v1/responses \ + -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "model": "weather_agent", diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index bcb21f4eee..76589216cb 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -4,8 +4,10 @@ import asyncio import inspect +import sys import tempfile from pathlib import Path +from typing import Any import pytest from conftest import MockAgent @@ -492,7 +494,7 @@ def test_devserver_requires_auth_by_default(monkeypatch): def test_devserver_auth_can_be_explicitly_disabled(monkeypatch): - """Callers can opt out of auth with auth_enabled=False (escape hatch for tests / trusted hosts).""" + """Callers can opt out of auth on loopback (escape hatch for tests / trusted local hosts).""" monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) server = _server_with_mock_agent(auth_enabled=False) @@ -504,6 +506,106 @@ def test_devserver_auth_can_be_explicitly_disabled(monkeypatch): assert response.status_code == 200 +def test_devserver_rejects_non_loopback_no_auth(monkeypatch): + """Non-loopback binds must not be network-reachable without authentication.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + DevServer(host="0.0.0.0", auth_enabled=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + DevServer(host="devui.example", auth_enabled=False) + + +def test_devserver_rejects_non_loopback_without_explicit_token(monkeypatch): + """Network-reachable auth requires an operator-provided token, not a generated token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"): + DevServer(host="0.0.0.0") + + +def test_devserver_allows_non_loopback_with_explicit_token(monkeypatch): + """A network-reachable bind is allowed when auth has an explicit token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(host="0.0.0.0", auth_token="s3cret") + + assert server.auth_enabled is True + assert server.auth_token == "s3cret" + + +def test_devserver_allows_non_loopback_with_env_token(monkeypatch): + """A network-reachable bind is allowed when auth uses DEVUI_AUTH_TOKEN.""" + monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret") + + server = DevServer(host="0.0.0.0") + + assert server.auth_enabled is True + assert server.auth_token == "env-s3cret" + + +def test_devserver_allows_loopback_no_auth(monkeypatch): + """Unauthenticated DevUI remains available for local-only development and tests.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + for host in ("127.0.0.1", "localhost"): + server = DevServer(host=host, auth_enabled=False) + assert server.auth_enabled is False + assert server.auth_token is None + + +def test_devserver_loopback_auth_auto_generates_token(monkeypatch): + """Loopback auth-enabled usage may still use a generated development token.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(host="127.0.0.1") + + assert server.auth_enabled is True + assert server.auth_token + + +def test_serve_rejects_non_loopback_no_auth(monkeypatch): + """The public serve() helper must inherit the DevServer network-auth invariant.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="authentication cannot be disabled"): + agent_framework_devui.serve(entities=[], host="0.0.0.0", auth_enabled=False, ui_enabled=False) + + +def test_serve_rejects_non_loopback_without_explicit_token(monkeypatch): + """serve() must not maintain a weaker generated-token path for network binds.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(ValueError, match="DEVUI_AUTH_TOKEN or auth_token"): + agent_framework_devui.serve(entities=[], host="0.0.0.0", ui_enabled=False) + + +def test_serve_allows_non_loopback_with_explicit_token(monkeypatch): + """serve() accepts a network bind when an explicit token is provided.""" + import uvicorn + + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + run_args = {} + + def fake_run(_app, *, host, port, **_kwargs): + run_args["host"] = host + run_args["port"] = port + + monkeypatch.setattr(uvicorn, "run", fake_run) + + agent_framework_devui.serve( + entities=[], + host="0.0.0.0", + port=9090, + auth_token="s3cret", + auto_open=False, + ui_enabled=False, + ) + + assert run_args == {"host": "0.0.0.0", "port": 9090} + + def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): """When auth is on, supplying the configured Bearer token grants access.""" monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) @@ -567,8 +669,8 @@ def test_serve_defaults_to_auth_enabled(): ) -def test_cli_enables_auth_by_default_and_supports_no_auth_optout(): - """`devui ./agents` must produce auth-enabled config; `--no-auth` is the explicit escape hatch.""" +def test_cli_enables_auth_by_default_and_supports_loopback_no_auth_optout(): + """`devui ./agents` must produce auth-enabled config; `--no-auth` is the loopback-only escape hatch.""" from agent_framework_devui._cli import create_cli_parser parser = create_cli_parser() @@ -578,3 +680,76 @@ def test_cli_enables_auth_by_default_and_supports_no_auth_optout(): optout_args = parser.parse_args(["--no-auth"]) assert optout_args.no_auth is True + + help_text = parser.format_help() + assert "loopback-only" in help_text + assert "Non-loopback hosts require auth" in help_text + + +def _run_cli_with_fake_uvicorn(monkeypatch, tmp_path: Path, *args: str) -> dict[str, Any]: + """Run the DevUI CLI without binding a socket.""" + import uvicorn + + from agent_framework_devui import _cli + + run_args: dict[str, Any] = {} + + def fake_run(_app, *, host, port, **_kwargs): + run_args["host"] = host + run_args["port"] = port + + monkeypatch.setattr(uvicorn, "run", fake_run) + monkeypatch.setattr(sys, "argv", ["devui", str(tmp_path), "--no-open", "--headless", *args]) + + _cli.main() + + return run_args + + +def test_cli_allows_loopback_no_auth_without_binding_socket(monkeypatch, tmp_path): + """`devui --no-auth` remains valid on the default loopback host.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--no-auth") + + assert run_args == {"host": "127.0.0.1", "port": 8080} + + +def test_cli_rejects_non_loopback_no_auth_before_binding_socket(monkeypatch, tmp_path, capsys): + """`devui --host 0.0.0.0 --no-auth` must fail through shared server validation.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(SystemExit) as exc_info: + _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--no-auth") + + assert exc_info.value.code == 1 + assert "authentication cannot be disabled" in capsys.readouterr().err + + +def test_cli_rejects_non_loopback_without_explicit_token_before_binding_socket(monkeypatch, tmp_path, capsys): + """`devui --host 0.0.0.0` must fail when neither --auth-token nor DEVUI_AUTH_TOKEN is set.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + with pytest.raises(SystemExit) as exc_info: + _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0") + + assert exc_info.value.code == 1 + assert "DEVUI_AUTH_TOKEN or auth_token" in capsys.readouterr().err + + +def test_cli_allows_non_loopback_with_auth_token_without_binding_socket(monkeypatch, tmp_path): + """`devui --host 0.0.0.0 --auth-token ...` starts with token auth enabled.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0", "--auth-token", "s3cret") + + assert run_args == {"host": "0.0.0.0", "port": 8080} + + +def test_cli_allows_non_loopback_with_env_token_without_binding_socket(monkeypatch, tmp_path): + """`DEVUI_AUTH_TOKEN=... devui --host 0.0.0.0` starts with token auth enabled.""" + monkeypatch.setenv("DEVUI_AUTH_TOKEN", "env-s3cret") + + run_args = _run_cli_with_fake_uvicorn(monkeypatch, tmp_path, "--host", "0.0.0.0") + + assert run_args == {"host": "0.0.0.0", "port": 8080}