agent-easy-framework 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_easy_framework-0.0.3.dist-info/METADATA +499 -0
- agent_easy_framework-0.0.3.dist-info/RECORD +68 -0
- agent_easy_framework-0.0.3.dist-info/WHEEL +4 -0
- agent_easy_framework-0.0.3.dist-info/entry_points.txt +2 -0
- agent_easy_framework-0.0.3.dist-info/licenses/LICENSE +21 -0
- agent_server/__about__.py +3 -0
- agent_server/__init__.py +13 -0
- agent_server/__main__.py +8 -0
- agent_server/auth/__init__.py +1 -0
- agent_server/auth/base.py +67 -0
- agent_server/auth/basic.py +95 -0
- agent_server/auth/none.py +28 -0
- agent_server/auth/oidc.py +114 -0
- agent_server/bootstrap.py +32 -0
- agent_server/checks/__init__.py +12 -0
- agent_server/checks/invariants.py +324 -0
- agent_server/cli.py +56 -0
- agent_server/config/__init__.py +1 -0
- agent_server/config/loader.py +102 -0
- agent_server/config/schema.py +61 -0
- agent_server/config/secrets/__init__.py +1 -0
- agent_server/config/secrets/base.py +53 -0
- agent_server/config/secrets/file_provider.py +46 -0
- agent_server/core/__init__.py +1 -0
- agent_server/core/context.py +54 -0
- agent_server/core/registry.py +155 -0
- agent_server/core/tools/__init__.py +12 -0
- agent_server/core/tools/example.py +28 -0
- agent_server/datasources/__init__.py +1 -0
- agent_server/datasources/base.py +61 -0
- agent_server/datasources/neo4j.py +72 -0
- agent_server/datasources/postgres.py +76 -0
- agent_server/docs/__init__.py +16 -0
- agent_server/docs/server.py +176 -0
- agent_server/ops/__init__.py +1 -0
- agent_server/ops/app.py +59 -0
- agent_server/ops/schemas.py +35 -0
- agent_server/py.typed +0 -0
- agent_server/runtime.py +39 -0
- agent_server/transports/__init__.py +55 -0
- agent_server/transports/dispatch.py +28 -0
- agent_server/transports/grpc/__init__.py +10 -0
- agent_server/transports/grpc/agent_server_pb2.py +45 -0
- agent_server/transports/grpc/agent_server_pb2_grpc.py +153 -0
- agent_server/transports/grpc/server.py +110 -0
- agent_server/transports/http.py +84 -0
- agent_server/transports/mcp_app.py +72 -0
- agent_server/transports/stdio.py +27 -0
- create_agent_server/__about__.py +7 -0
- create_agent_server/__init__.py +17 -0
- create_agent_server/add_tool.py +167 -0
- create_agent_server/cli.py +205 -0
- create_agent_server/generator.py +832 -0
- create_agent_server/py.typed +0 -0
- create_agent_server/template_paths.py +89 -0
- create_agent_server/templates/config/base.yaml +25 -0
- create_agent_server/templates/config/profiles/local-http.yaml +10 -0
- create_agent_server/templates/config/profiles/local.yaml +6 -0
- create_agent_server/templates/config/profiles/prod.yaml +32 -0
- create_agent_server/templates/examples/README.md +125 -0
- create_agent_server/templates/examples/call_ping.py +43 -0
- create_agent_server/templates/examples/call_ping_http.py +52 -0
- create_agent_server/templates/examples/smoke_ops.sh +26 -0
- create_agent_server/templates/gitignore +15 -0
- create_agent_server/templates/pre-commit-config.yaml +42 -0
- create_agent_server/templates/proto/agent_server.proto +35 -0
- create_agent_server/templates/tools/checks/run_checks.py +63 -0
- create_agent_server/templates/tools/checks/validate_typing.py +74 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Pluggable authentication.
|
|
2
|
+
|
|
3
|
+
Auth providers validate inbound credentials and return a :class:`Principal`, or
|
|
4
|
+
raise :class:`AuthError`. They register by name and are selected per profile via
|
|
5
|
+
``server.auth.provider`` - the same plugin pattern as secrets and data sources.
|
|
6
|
+
Enforced on the HTTP and gRPC transports; stdio is a trusted local channel.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(Exception):
|
|
17
|
+
"""Raised when authentication fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Principal:
|
|
22
|
+
"""The authenticated caller."""
|
|
23
|
+
|
|
24
|
+
subject: str
|
|
25
|
+
claims: Mapping[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@runtime_checkable
|
|
29
|
+
class AuthProvider(Protocol):
|
|
30
|
+
"""Validates credentials carried in transport headers/metadata."""
|
|
31
|
+
|
|
32
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
33
|
+
"""Return the Principal for valid credentials, else raise AuthError."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
AuthProviderFactory = Callable[[Mapping[str, Any]], AuthProvider]
|
|
38
|
+
|
|
39
|
+
_REGISTRY: dict[str, AuthProviderFactory] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_auth_provider(
|
|
43
|
+
name: str,
|
|
44
|
+
) -> Callable[[AuthProviderFactory], AuthProviderFactory]:
|
|
45
|
+
"""Register an auth provider factory under ``name``."""
|
|
46
|
+
|
|
47
|
+
def decorate(factory: AuthProviderFactory) -> AuthProviderFactory:
|
|
48
|
+
if name in _REGISTRY:
|
|
49
|
+
raise ValueError(f"Auth provider {name!r} already registered")
|
|
50
|
+
_REGISTRY[name] = factory
|
|
51
|
+
return factory
|
|
52
|
+
|
|
53
|
+
return decorate
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_auth_provider(name: str, options: Mapping[str, Any]) -> AuthProvider:
|
|
57
|
+
"""Instantiate a registered auth provider by name."""
|
|
58
|
+
try:
|
|
59
|
+
factory = _REGISTRY[name]
|
|
60
|
+
except KeyError as exc:
|
|
61
|
+
known = ", ".join(sorted(_REGISTRY)) or "<none>"
|
|
62
|
+
raise KeyError(f"Unknown auth provider {name!r}. Registered: {known}") from exc
|
|
63
|
+
return factory(options)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def registered_auth_providers() -> tuple[str, ...]:
|
|
67
|
+
return tuple(sorted(_REGISTRY))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Ephemeral API-key authentication for local testing.
|
|
2
|
+
|
|
3
|
+
This provider generates a fresh, random API key at construction time and
|
|
4
|
+
accepts only that key. It exists so that a freshly scaffolded server can be
|
|
5
|
+
exercised over an authenticated transport without standing up a real identity
|
|
6
|
+
provider.
|
|
7
|
+
|
|
8
|
+
It is deliberately **not** production-safe: the key lives only in process
|
|
9
|
+
memory, rotates on every restart, and is printed to the logs. To make that
|
|
10
|
+
impossible to miss, construction emits a loud, bordered WARNING that repeats
|
|
11
|
+
the key and states it is for testing only. Real deployments must select the
|
|
12
|
+
``oidc`` provider instead.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import secrets
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from agent_server.auth.base import (
|
|
23
|
+
AuthError,
|
|
24
|
+
AuthProvider,
|
|
25
|
+
Principal,
|
|
26
|
+
register_auth_provider,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize(headers: Mapping[str, str]) -> dict[str, str]:
|
|
33
|
+
"""Return a case-insensitive view of ``headers`` keyed by lower-case name."""
|
|
34
|
+
return {key.lower(): value for key, value in headers.items()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BasicAuthProvider:
|
|
38
|
+
"""Accept a single, runtime-generated API key.
|
|
39
|
+
|
|
40
|
+
The key is created with :func:`secrets.token_urlsafe` and compared in
|
|
41
|
+
constant time. Callers may present it as either ``Authorization: Bearer
|
|
42
|
+
<key>`` or ``X-Api-Key: <key>``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, *, subject: str = "test-user") -> None:
|
|
46
|
+
self.subject = subject
|
|
47
|
+
self.api_key: str = secrets.token_urlsafe(32)
|
|
48
|
+
self._warn_testing_only()
|
|
49
|
+
|
|
50
|
+
def _warn_testing_only(self) -> None:
|
|
51
|
+
"""Emit a hard-to-miss warning so the ephemeral key is never trusted."""
|
|
52
|
+
border = "=" * 72
|
|
53
|
+
logger.warning(
|
|
54
|
+
"\n%s\n"
|
|
55
|
+
"BasicAuthProvider generated an ephemeral API key:\n"
|
|
56
|
+
" %s\n"
|
|
57
|
+
"FOR TESTING PURPOSES ONLY - DO NOT USE IN PRODUCTION.\n"
|
|
58
|
+
"%s",
|
|
59
|
+
border,
|
|
60
|
+
self.api_key,
|
|
61
|
+
border,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
65
|
+
"""Validate the presented key and return the configured principal.
|
|
66
|
+
|
|
67
|
+
Raises :class:`AuthError` when no key is supplied or it does not match.
|
|
68
|
+
"""
|
|
69
|
+
presented = self._extract_key(headers)
|
|
70
|
+
if presented is None or not secrets.compare_digest(presented, self.api_key):
|
|
71
|
+
raise AuthError("Invalid or missing API key")
|
|
72
|
+
return Principal(
|
|
73
|
+
subject=self.subject,
|
|
74
|
+
claims={"auth": "basic", "testing_only": True},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _extract_key(headers: Mapping[str, str]) -> str | None:
|
|
79
|
+
"""Read the key from an ``Authorization`` bearer or ``X-Api-Key`` header."""
|
|
80
|
+
normalized = _normalize(headers)
|
|
81
|
+
api_key = normalized.get("x-api-key")
|
|
82
|
+
if api_key:
|
|
83
|
+
return api_key.strip()
|
|
84
|
+
value = normalized.get("authorization")
|
|
85
|
+
if value:
|
|
86
|
+
scheme, _, token = value.partition(" ")
|
|
87
|
+
if scheme.lower() == "bearer" and token.strip():
|
|
88
|
+
return token.strip()
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@register_auth_provider("basic")
|
|
93
|
+
def _build(options: Mapping[str, Any]) -> AuthProvider:
|
|
94
|
+
"""Construct a :class:`BasicAuthProvider` from profile options."""
|
|
95
|
+
return BasicAuthProvider(subject=options.get("subject", "test-user"))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The default 'no authentication' provider.
|
|
2
|
+
|
|
3
|
+
Selected when ``server.auth.provider`` is ``none`` (the default). Every request
|
|
4
|
+
is accepted as an anonymous principal. Appropriate for stdio and trusted
|
|
5
|
+
internal networks; production HTTP/gRPC deployments should select ``oidc``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agent_server.auth.base import AuthProvider, Principal, register_auth_provider
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoAuthProvider:
|
|
17
|
+
"""Accept every caller as anonymous."""
|
|
18
|
+
|
|
19
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
20
|
+
# No credentials are inspected; every caller is anonymous. We bind the
|
|
21
|
+
# parameter explicitly so the interface stays honest under all builds.
|
|
22
|
+
_ = headers
|
|
23
|
+
return Principal(subject="anonymous", claims={})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@register_auth_provider("none")
|
|
27
|
+
def _build(options: Mapping[str, Any]) -> AuthProvider:
|
|
28
|
+
return NoAuthProvider()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""OIDC / OAuth2 bearer-JWT authentication.
|
|
2
|
+
|
|
3
|
+
Validates inbound ``Authorization: Bearer <jwt>`` credentials against an
|
|
4
|
+
OpenID Connect / OAuth2 identity provider. Signing keys are fetched from the
|
|
5
|
+
issuer's JWKS endpoint and the token is verified for signature, ``audience``,
|
|
6
|
+
and ``issuer``. This is the recommended provider for production HTTP/gRPC
|
|
7
|
+
deployments.
|
|
8
|
+
|
|
9
|
+
The heavy dependencies (``PyJWT`` and its crypto backend, plus the JWKS HTTP
|
|
10
|
+
client) are imported lazily inside :meth:`OidcAuthProvider.authenticate`. A
|
|
11
|
+
bootstrap importer loads every provider module unconditionally, so importing
|
|
12
|
+
this module must never require the optional ``oidc`` extra to be installed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
from agent_server.auth.base import (
|
|
21
|
+
AuthError,
|
|
22
|
+
AuthProvider,
|
|
23
|
+
Principal,
|
|
24
|
+
register_auth_provider,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from jwt import PyJWKClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize(headers: Mapping[str, str]) -> dict[str, str]:
|
|
32
|
+
"""Return a case-insensitive view of ``headers`` keyed by lower-case name."""
|
|
33
|
+
return {key.lower(): value for key, value in headers.items()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OidcAuthProvider:
|
|
37
|
+
"""Authenticate callers via signed OIDC/OAuth2 bearer JWTs.
|
|
38
|
+
|
|
39
|
+
The JWKS client is created on first use and cached on the instance so that
|
|
40
|
+
signing keys are reused across requests (PyJWKClient performs its own key
|
|
41
|
+
caching internally).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
issuer: str,
|
|
48
|
+
audience: str,
|
|
49
|
+
jwks_uri: str,
|
|
50
|
+
algorithms: Sequence[str] | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.issuer = issuer
|
|
53
|
+
self.audience = audience
|
|
54
|
+
self.jwks_uri = jwks_uri
|
|
55
|
+
self.algorithms: list[str] = list(algorithms) if algorithms else ["RS256"]
|
|
56
|
+
self._jwks_client: PyJWKClient | None = None
|
|
57
|
+
|
|
58
|
+
def _client(self) -> PyJWKClient:
|
|
59
|
+
"""Lazily construct and cache the JWKS client."""
|
|
60
|
+
if self._jwks_client is None:
|
|
61
|
+
from jwt import PyJWKClient # noqa: PLC0415
|
|
62
|
+
|
|
63
|
+
self._jwks_client = PyJWKClient(self.jwks_uri)
|
|
64
|
+
return self._jwks_client
|
|
65
|
+
|
|
66
|
+
async def authenticate(self, headers: Mapping[str, str]) -> Principal:
|
|
67
|
+
"""Validate the bearer JWT and return the caller's :class:`Principal`.
|
|
68
|
+
|
|
69
|
+
Raises :class:`AuthError` if the ``Authorization`` header is missing or
|
|
70
|
+
malformed, or if signature/claim verification fails.
|
|
71
|
+
"""
|
|
72
|
+
token = self._extract_bearer(headers)
|
|
73
|
+
|
|
74
|
+
import jwt # noqa: PLC0415
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
signing_key = self._client().get_signing_key_from_jwt(token)
|
|
78
|
+
claims: dict[str, Any] = jwt.decode(
|
|
79
|
+
token,
|
|
80
|
+
signing_key.key,
|
|
81
|
+
algorithms=self.algorithms,
|
|
82
|
+
audience=self.audience,
|
|
83
|
+
issuer=self.issuer,
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise AuthError("OIDC token verification failed") from exc
|
|
87
|
+
|
|
88
|
+
subject = claims.get("sub")
|
|
89
|
+
if not isinstance(subject, str) or not subject:
|
|
90
|
+
raise AuthError("OIDC token missing 'sub' claim")
|
|
91
|
+
return Principal(subject=subject, claims=claims)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _extract_bearer(headers: Mapping[str, str]) -> str:
|
|
95
|
+
"""Pull the raw token out of an ``Authorization: Bearer <token>`` header."""
|
|
96
|
+
normalized = _normalize(headers)
|
|
97
|
+
value = normalized.get("authorization")
|
|
98
|
+
if not value:
|
|
99
|
+
raise AuthError("Missing Authorization header")
|
|
100
|
+
scheme, _, token = value.partition(" ")
|
|
101
|
+
if scheme.lower() != "bearer" or not token.strip():
|
|
102
|
+
raise AuthError("Malformed Authorization header; expected 'Bearer <token>'")
|
|
103
|
+
return token.strip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@register_auth_provider("oidc")
|
|
107
|
+
def _build(options: Mapping[str, Any]) -> AuthProvider:
|
|
108
|
+
"""Construct an :class:`OidcAuthProvider` from profile options."""
|
|
109
|
+
return OidcAuthProvider(
|
|
110
|
+
issuer=options["issuer"],
|
|
111
|
+
audience=options["audience"],
|
|
112
|
+
jwks_uri=options["jwks_uri"],
|
|
113
|
+
algorithms=options.get("algorithms"),
|
|
114
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Import-time registration of all pluggable components.
|
|
2
|
+
|
|
3
|
+
Calling :func:`bootstrap` imports every provider and tool module so their
|
|
4
|
+
``@register_*`` / ``@tool`` side effects populate the registries. Provider
|
|
5
|
+
modules must keep heavy third-party imports inside their factories (not at
|
|
6
|
+
module top level) so this stays import-safe even when an optional dependency is
|
|
7
|
+
not installed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
|
|
14
|
+
_MODULES: tuple[str, ...] = (
|
|
15
|
+
"agent_server.config.secrets.file_provider",
|
|
16
|
+
"agent_server.auth.none",
|
|
17
|
+
"agent_server.auth.oidc",
|
|
18
|
+
"agent_server.auth.basic",
|
|
19
|
+
"agent_server.datasources.postgres",
|
|
20
|
+
"agent_server.datasources.neo4j",
|
|
21
|
+
"agent_server.core.tools",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_imported: set[str] = set()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bootstrap() -> None:
|
|
28
|
+
"""Idempotently import all registration modules."""
|
|
29
|
+
for module in _MODULES:
|
|
30
|
+
if module not in _imported:
|
|
31
|
+
importlib.import_module(module)
|
|
32
|
+
_imported.add(module)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Golden-path fact-checks: importable invariants for an agent-server project.
|
|
2
|
+
|
|
3
|
+
These checks codify what it means for a project scaffolded by this framework to
|
|
4
|
+
stay on the golden path. They are deliberately importable (not just a script) so
|
|
5
|
+
pre-commit, ``make factcheck``, and tests can all share one implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from agent_server.checks.invariants import Problem, check_project
|
|
11
|
+
|
|
12
|
+
__all__ = ["Problem", "check_project"]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The golden-path invariants, as importable fact-check logic.
|
|
2
|
+
|
|
3
|
+
:func:`check_project` runs eight invariants against a project directory and returns
|
|
4
|
+
one :class:`Problem` per violation (an empty list means the project is healthy).
|
|
5
|
+
The checks introspect the *running* code where structure alone cannot prove
|
|
6
|
+
correctness - importing the project package, bootstrapping its registries, and
|
|
7
|
+
loading every config profile. Every check degrades gracefully: if something
|
|
8
|
+
cannot be loaded it reports a ``Problem`` rather than raising, so a broken
|
|
9
|
+
sub-system never masks the rest of the report.
|
|
10
|
+
|
|
11
|
+
The single source of truth for "the package under test" is :data:`PACKAGE`. For
|
|
12
|
+
this repository that is ``agent_server``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib
|
|
18
|
+
import inspect
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from pydantic import BaseModel
|
|
27
|
+
|
|
28
|
+
PACKAGE = "agent_server"
|
|
29
|
+
|
|
30
|
+
# Keys whose string values must never hold a raw secret. Matched case-insensitively
|
|
31
|
+
# as a substring so e.g. ``db_password`` and ``apiKey`` are caught.
|
|
32
|
+
_SECRET_KEY = re.compile(r"(password|secret|token|apikey|api_key|private_key)", re.IGNORECASE)
|
|
33
|
+
# A value is acceptable iff it is purely a ``${secret:...}`` reference.
|
|
34
|
+
_SECRET_REF = re.compile(r"\$\{secret:[^}]+\}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Problem:
|
|
39
|
+
"""A single invariant violation: a stable ``code`` and a human ``message``."""
|
|
40
|
+
|
|
41
|
+
code: str
|
|
42
|
+
message: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def check_project(project_dir: Path) -> list[Problem]:
|
|
46
|
+
"""Run every golden-path invariant and collect the violations."""
|
|
47
|
+
project_dir = project_dir.resolve()
|
|
48
|
+
problems: list[Problem] = []
|
|
49
|
+
problems += _check_required_files(project_dir)
|
|
50
|
+
problems += _check_ops_endpoints()
|
|
51
|
+
problems += _check_tool_contract()
|
|
52
|
+
problems += _check_config_and_secrets(project_dir)
|
|
53
|
+
problems += _check_datasource_resolution(project_dir)
|
|
54
|
+
problems += _check_docs_in_sync()
|
|
55
|
+
problems += _check_ops_typed_responses()
|
|
56
|
+
problems += _check_transport_separation()
|
|
57
|
+
return problems
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _check_required_files(project_dir: Path) -> list[Problem]:
|
|
61
|
+
"""Invariant 1: the project layout the framework guarantees is present."""
|
|
62
|
+
problems: list[Problem] = []
|
|
63
|
+
|
|
64
|
+
base = project_dir / "config" / "base.yaml"
|
|
65
|
+
if not base.is_file():
|
|
66
|
+
problems.append(Problem("files.base_config", "config/base.yaml is missing"))
|
|
67
|
+
|
|
68
|
+
profiles = project_dir / "config" / "profiles"
|
|
69
|
+
if not profiles.is_dir():
|
|
70
|
+
problems.append(Problem("files.profiles_dir", "config/profiles/ is missing"))
|
|
71
|
+
elif not any(profiles.glob("*.yaml")) and not any(profiles.glob("*.yml")):
|
|
72
|
+
problems.append(
|
|
73
|
+
Problem("files.profiles_empty", "config/profiles/ contains no profile YAML")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
src = project_dir / "src"
|
|
77
|
+
packages = (
|
|
78
|
+
[p for p in src.iterdir() if p.is_dir() and (p / "__init__.py").is_file()]
|
|
79
|
+
if src.is_dir()
|
|
80
|
+
else []
|
|
81
|
+
)
|
|
82
|
+
if not packages:
|
|
83
|
+
problems.append(Problem("files.src_package", "no src/<pkg> package with __init__.py found"))
|
|
84
|
+
|
|
85
|
+
for name in ("Makefile", ".pre-commit-config.yaml"):
|
|
86
|
+
if not (project_dir / name).is_file():
|
|
87
|
+
problems.append(Problem("files.required", f"{name} is missing"))
|
|
88
|
+
|
|
89
|
+
return problems
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_ops_endpoints() -> list[Problem]:
|
|
93
|
+
"""Invariant 2: the ops app exposes /health and /ready."""
|
|
94
|
+
try:
|
|
95
|
+
ctx = _build_context()
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
return [Problem("ops.context", f"could not build app context: {exc}")]
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
ops = importlib.import_module(f"{PACKAGE}.ops.app")
|
|
101
|
+
app = ops.create_ops_app(ctx)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
return [Problem("ops.import", f"could not build ops app: {exc}")]
|
|
104
|
+
|
|
105
|
+
paths = {getattr(route, "path", None) for route in app.routes}
|
|
106
|
+
return [
|
|
107
|
+
Problem("ops.endpoint", f"ops app does not expose {required}")
|
|
108
|
+
for required in ("/health", "/ready")
|
|
109
|
+
if required not in paths
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_tool_contract() -> list[Problem]:
|
|
114
|
+
"""Invariant 3: every registered tool honours the tool contract."""
|
|
115
|
+
try:
|
|
116
|
+
registry = _registry()
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
return [Problem("tools.registry", f"could not load tool registry: {exc}")]
|
|
119
|
+
|
|
120
|
+
problems: list[Problem] = []
|
|
121
|
+
for spec in registry.all():
|
|
122
|
+
label = getattr(spec, "name", None) or "<unnamed>"
|
|
123
|
+
if not getattr(spec, "name", ""):
|
|
124
|
+
problems.append(Problem("tools.name", "a tool has an empty name"))
|
|
125
|
+
if not (getattr(spec, "description", "") or "").strip():
|
|
126
|
+
problems.append(Problem("tools.description", f"tool {label!r} has no description"))
|
|
127
|
+
model = getattr(spec, "input_model", None)
|
|
128
|
+
if not (isinstance(model, type) and issubclass(model, BaseModel)):
|
|
129
|
+
problems.append(
|
|
130
|
+
Problem("tools.input_model", f"tool {label!r} input_model is not a BaseModel")
|
|
131
|
+
)
|
|
132
|
+
output = getattr(spec, "output_model", None)
|
|
133
|
+
if not (isinstance(output, type) and issubclass(output, BaseModel)):
|
|
134
|
+
problems.append(
|
|
135
|
+
Problem("tools.output_model", f"tool {label!r} output_model is not a BaseModel")
|
|
136
|
+
)
|
|
137
|
+
return problems
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_config_and_secrets(project_dir: Path) -> list[Problem]:
|
|
141
|
+
"""Invariant 4: every profile validates, and no raw secrets are committed."""
|
|
142
|
+
problems: list[Problem] = []
|
|
143
|
+
config_dir = project_dir / "config"
|
|
144
|
+
profiles_dir = config_dir / "profiles"
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
loader = importlib.import_module(f"{PACKAGE}.config.loader")
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
return [Problem("config.loader", f"could not import config loader: {exc}")]
|
|
150
|
+
|
|
151
|
+
profile_files = sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml"))
|
|
152
|
+
for profile_file in profile_files:
|
|
153
|
+
name = profile_file.stem
|
|
154
|
+
try:
|
|
155
|
+
loader.load_config(profile=name, config_dir=config_dir, resolve_secrets=False)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
problems.append(
|
|
158
|
+
Problem("config.invalid", f"profile {name!r} fails to load/validate: {exc}")
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
base = config_dir / "base.yaml"
|
|
162
|
+
for yaml_file in ([base] if base.is_file() else []) + profile_files:
|
|
163
|
+
problems += _scan_for_raw_secrets(yaml_file)
|
|
164
|
+
|
|
165
|
+
return problems
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _scan_for_raw_secrets(path: Path) -> list[Problem]:
|
|
169
|
+
try:
|
|
170
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
return [Problem("config.unreadable", f"{path.name} could not be parsed: {exc}")]
|
|
173
|
+
|
|
174
|
+
problems: list[Problem] = []
|
|
175
|
+
for key, value in _walk(data):
|
|
176
|
+
if (
|
|
177
|
+
isinstance(value, str)
|
|
178
|
+
and value.strip()
|
|
179
|
+
and _SECRET_KEY.search(key)
|
|
180
|
+
and not _SECRET_REF.fullmatch(value.strip())
|
|
181
|
+
):
|
|
182
|
+
problems.append(
|
|
183
|
+
Problem(
|
|
184
|
+
"secrets.raw",
|
|
185
|
+
f"{path.name}: key {key!r} looks like a raw secret; "
|
|
186
|
+
f"use a ${{secret:...}} reference",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return problems
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _walk(node: Any, key: str = "") -> Iterator[tuple[str, Any]]:
|
|
193
|
+
"""Yield ``(key, value)`` for every scalar leaf in a nested structure."""
|
|
194
|
+
if isinstance(node, dict):
|
|
195
|
+
for k, v in node.items():
|
|
196
|
+
yield from _walk(v, str(k))
|
|
197
|
+
elif isinstance(node, list):
|
|
198
|
+
for item in node:
|
|
199
|
+
yield from _walk(item, key)
|
|
200
|
+
else:
|
|
201
|
+
yield key, node
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _check_datasource_resolution(project_dir: Path) -> list[Problem]:
|
|
205
|
+
"""Invariant 5: every datasource type used in any profile is registered."""
|
|
206
|
+
config_dir = project_dir / "config"
|
|
207
|
+
profiles_dir = config_dir / "profiles"
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
_bootstrap()
|
|
211
|
+
ds_base = importlib.import_module(f"{PACKAGE}.datasources.base")
|
|
212
|
+
loader = importlib.import_module(f"{PACKAGE}.config.loader")
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
return [Problem("datasources.import", f"could not load datasource registry: {exc}")]
|
|
215
|
+
|
|
216
|
+
registered = set(ds_base.registered_datasources())
|
|
217
|
+
problems: list[Problem] = []
|
|
218
|
+
seen: set[tuple[str, str]] = set()
|
|
219
|
+
for profile_file in sorted(profiles_dir.glob("*.yaml")) + sorted(profiles_dir.glob("*.yml")):
|
|
220
|
+
name = profile_file.stem
|
|
221
|
+
try:
|
|
222
|
+
cfg = loader.load_config(profile=name, config_dir=config_dir, resolve_secrets=False)
|
|
223
|
+
except Exception:
|
|
224
|
+
continue
|
|
225
|
+
for ds_name, ds in cfg.server.datasources.items():
|
|
226
|
+
if ds.type in registered:
|
|
227
|
+
continue
|
|
228
|
+
marker = (name, ds.type)
|
|
229
|
+
if marker in seen:
|
|
230
|
+
continue
|
|
231
|
+
seen.add(marker)
|
|
232
|
+
problems.append(
|
|
233
|
+
Problem(
|
|
234
|
+
"datasources.unregistered",
|
|
235
|
+
f"profile {name!r} datasource {ds_name!r} uses unregistered type {ds.type!r}",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
return problems
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _check_docs_in_sync() -> list[Problem]:
|
|
242
|
+
"""Invariant 6: the docs tool list covers every registered tool."""
|
|
243
|
+
try:
|
|
244
|
+
ctx = _build_context()
|
|
245
|
+
docs = importlib.import_module(f"{PACKAGE}.docs.server")
|
|
246
|
+
registry = _registry()
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
return [Problem("docs.import", f"could not load docs generator: {exc}")]
|
|
249
|
+
|
|
250
|
+
documented = {str(entry["name"]) for entry in docs.tool_index(ctx)}
|
|
251
|
+
registered = set(registry.names())
|
|
252
|
+
missing = registered - documented
|
|
253
|
+
return [
|
|
254
|
+
Problem("docs.drift", f"tool {name!r} is registered but missing from the docs index")
|
|
255
|
+
for name in sorted(missing)
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _check_ops_typed_responses() -> list[Problem]:
|
|
260
|
+
"""Invariant 7: ops endpoints declare Pydantic response models."""
|
|
261
|
+
try:
|
|
262
|
+
ctx = _build_context()
|
|
263
|
+
ops = importlib.import_module(f"{PACKAGE}.ops.app")
|
|
264
|
+
app = ops.create_ops_app(ctx)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
return [Problem("ops.import", f"could not build ops app: {exc}")]
|
|
267
|
+
|
|
268
|
+
problems: list[Problem] = []
|
|
269
|
+
for route in app.routes:
|
|
270
|
+
path = getattr(route, "path", None)
|
|
271
|
+
if path not in {"/health", "/ready", "/admin/tools"}:
|
|
272
|
+
continue
|
|
273
|
+
model = getattr(route, "response_model", None)
|
|
274
|
+
if model is None:
|
|
275
|
+
problems.append(
|
|
276
|
+
Problem("ops.response_model", f"ops route {path!r} has no response_model")
|
|
277
|
+
)
|
|
278
|
+
return problems
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_transport_separation() -> list[Problem]:
|
|
282
|
+
"""Invariant 8: auth stays in transport middleware; service layer stays typed."""
|
|
283
|
+
problems: list[Problem] = []
|
|
284
|
+
try:
|
|
285
|
+
mcp_app = importlib.import_module(f"{PACKAGE}.transports.mcp_app")
|
|
286
|
+
importlib.import_module(f"{PACKAGE}.transports.dispatch")
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
return [Problem("transport.import", f"could not import transport modules: {exc}")]
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
http = importlib.import_module(f"{PACKAGE}.transports.http")
|
|
292
|
+
except ModuleNotFoundError:
|
|
293
|
+
http = None
|
|
294
|
+
|
|
295
|
+
if http is not None and not hasattr(http, "_AuthMiddleware"):
|
|
296
|
+
problems.append(Problem("transport.auth", "HTTP transport is missing _AuthMiddleware"))
|
|
297
|
+
|
|
298
|
+
mcp_source = inspect.getsource(mcp_app)
|
|
299
|
+
if "authenticate" in mcp_source or "AuthProvider" in mcp_source:
|
|
300
|
+
problems.append(
|
|
301
|
+
Problem(
|
|
302
|
+
"transport.auth_leak",
|
|
303
|
+
"mcp_app must not perform auth; use transport middleware instead",
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return problems
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _bootstrap() -> None:
|
|
311
|
+
importlib.import_module(f"{PACKAGE}.bootstrap").bootstrap()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _registry() -> Any:
|
|
315
|
+
_bootstrap()
|
|
316
|
+
return importlib.import_module(f"{PACKAGE}.core.registry").get_registry()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _build_context() -> Any:
|
|
320
|
+
"""Bootstrap registries, load the active config, and build an AppContext."""
|
|
321
|
+
_bootstrap()
|
|
322
|
+
loader = importlib.import_module(f"{PACKAGE}.config.loader")
|
|
323
|
+
context = importlib.import_module(f"{PACKAGE}.core.context")
|
|
324
|
+
return context.AppContext(loader.load_config(resolve_secrets=False))
|