openagent-control 0.1.0__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.
- openagent_control/__init__.py +0 -0
- openagent_control/adapters/__init__.py +0 -0
- openagent_control/adapters/audit_export/__init__.py +0 -0
- openagent_control/adapters/audit_export/stdout.py +36 -0
- openagent_control/adapters/db/__init__.py +0 -0
- openagent_control/adapters/db/session.py +26 -0
- openagent_control/adapters/db/tables.py +68 -0
- openagent_control/adapters/identity/__init__.py +0 -0
- openagent_control/adapters/identity/header.py +24 -0
- openagent_control/adapters/identity/jwt_svid.py +74 -0
- openagent_control/adapters/identity/oidc_jwks.py +121 -0
- openagent_control/adapters/ledger/__init__.py +0 -0
- openagent_control/adapters/ledger/ed25519_chain.py +57 -0
- openagent_control/adapters/ledger/postgres.py +77 -0
- openagent_control/adapters/ledger/signing.py +30 -0
- openagent_control/adapters/mcp_upstream/__init__.py +0 -0
- openagent_control/adapters/mcp_upstream/http.py +40 -0
- openagent_control/adapters/mcp_upstream/streamable_http.py +93 -0
- openagent_control/adapters/policy/__init__.py +0 -0
- openagent_control/adapters/policy/opa.py +46 -0
- openagent_control/adapters/registry/__init__.py +0 -0
- openagent_control/adapters/registry/caching.py +34 -0
- openagent_control/adapters/registry/file.py +38 -0
- openagent_control/adapters/registry/postgres.py +42 -0
- openagent_control/adapters/token_exchange/__init__.py +0 -0
- openagent_control/adapters/token_exchange/caching.py +73 -0
- openagent_control/adapters/token_exchange/entra_obo.py +52 -0
- openagent_control/adapters/token_exchange/rfc8693.py +50 -0
- openagent_control/adapters/token_exchange/stub.py +12 -0
- openagent_control/alembic.ini +36 -0
- openagent_control/application/__init__.py +0 -0
- openagent_control/application/governed_execution.py +177 -0
- openagent_control/cli.py +162 -0
- openagent_control/config.py +67 -0
- openagent_control/diagnostics.py +120 -0
- openagent_control/domain/__init__.py +0 -0
- openagent_control/domain/errors.py +31 -0
- openagent_control/domain/models.py +93 -0
- openagent_control/domain/ports.py +103 -0
- openagent_control/gateway/__init__.py +0 -0
- openagent_control/gateway/app.py +51 -0
- openagent_control/gateway/dependencies.py +212 -0
- openagent_control/gateway/routes/__init__.py +0 -0
- openagent_control/gateway/routes/mcp.py +51 -0
- openagent_control/migrations/env.py +52 -0
- openagent_control/migrations/script.py.mako +27 -0
- openagent_control/migrations/versions/0001_initial_schema.py +82 -0
- openagent_control/py.typed +0 -0
- openagent_control/resources/__init__.py +72 -0
- openagent_control/resources/agents.example.yaml +32 -0
- openagent_control/resources/policies/mcp_authz.rego +47 -0
- openagent_control-0.1.0.dist-info/METADATA +241 -0
- openagent_control-0.1.0.dist-info/RECORD +56 -0
- openagent_control-0.1.0.dist-info/WHEEL +4 -0
- openagent_control-0.1.0.dist-info/entry_points.txt +3 -0
- openagent_control-0.1.0.dist-info/licenses/LICENSE +201 -0
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""stdout/log audit exporter. See docs/adr/0006-hexagonal-architecture-for-the-control-plane.md.
|
|
2
|
+
|
|
3
|
+
v1 placeholder for the `AuditExporter` port; real deployments swap this for a
|
|
4
|
+
Datadog/Grafana(-OTLP)/Splunk adapter behind the same port. The full receipt is
|
|
5
|
+
serialized into the log message itself so it survives any logging formatter — an
|
|
6
|
+
audit record carried only in `extra` would be dropped by the default formatter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from openagent_control.domain.models import ExecutionReceipt
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("openagent_control.audit")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StdoutAuditExporter:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
# This adapter's contract is "receipts reach stdout". Host processes
|
|
23
|
+
# (e.g. uvicorn) configure their own loggers and leave the root logger
|
|
24
|
+
# handler-less at WARNING, which would silently drop INFO receipts —
|
|
25
|
+
# so attach a handler explicitly unless one is already present.
|
|
26
|
+
if not logger.handlers:
|
|
27
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
28
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(message)s"))
|
|
29
|
+
logger.addHandler(handler)
|
|
30
|
+
logger.setLevel(logging.INFO)
|
|
31
|
+
# propagate stays True: root-level pipelines (pytest caplog, host log
|
|
32
|
+
# shippers) still see receipts; worst case is a duplicate line, never
|
|
33
|
+
# a dropped one.
|
|
34
|
+
|
|
35
|
+
async def export(self, receipt: ExecutionReceipt) -> None:
|
|
36
|
+
logger.info("audit_receipt %s", json.dumps(receipt.model_dump(mode="json"), sort_keys=True))
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Async SQLAlchemy engine/session factory shared by the Postgres adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import (
|
|
6
|
+
AsyncEngine,
|
|
7
|
+
AsyncSession,
|
|
8
|
+
async_sessionmaker,
|
|
9
|
+
create_async_engine,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from openagent_control.adapters.db.tables import SCHEMA
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_engine(database_url: str) -> AsyncEngine:
|
|
16
|
+
engine = create_async_engine(database_url, pool_pre_ping=True)
|
|
17
|
+
if database_url.startswith("sqlite"):
|
|
18
|
+
# SQLite has no schema concept equivalent to Postgres's CREATE SCHEMA;
|
|
19
|
+
# map the `oac` schema declared on Base.metadata down to "no schema" so
|
|
20
|
+
# the same ORM models work against an in-memory SQLite test database.
|
|
21
|
+
engine = engine.execution_options(schema_translate_map={SCHEMA: None})
|
|
22
|
+
return engine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def make_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
|
26
|
+
return async_sessionmaker(engine, expire_on_commit=False)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""SQLAlchemy table definitions backing the Postgres registry and ledger (ADR-0009).
|
|
2
|
+
|
|
3
|
+
Plain `JSON` (not Postgres `JSONB`) so the same schema runs against SQLite in
|
|
4
|
+
tests without a real Postgres server. `Alembic` migrations in migrations/ create
|
|
5
|
+
these tables against whatever database the operator points `database_url` at.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import JSON, DateTime, Integer, MetaData, String
|
|
13
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
14
|
+
|
|
15
|
+
SCHEMA = "oac"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Base(DeclarativeBase):
|
|
19
|
+
"""All tables live in the `oac` Postgres schema, not `public` — keeps the
|
|
20
|
+
control plane's tables isolated from whatever else lives in the operator's
|
|
21
|
+
database. The Alembic migration creates the schema (`CREATE SCHEMA IF NOT
|
|
22
|
+
EXISTS oac`) before creating tables in it. Tests run against SQLite via a
|
|
23
|
+
schema_translate_map (see adapters/db/session.py) since SQLite has no
|
|
24
|
+
equivalent concept."""
|
|
25
|
+
|
|
26
|
+
metadata = MetaData(schema=SCHEMA)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentRow(Base):
|
|
30
|
+
__tablename__ = "agents"
|
|
31
|
+
|
|
32
|
+
spiffe_id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
33
|
+
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
|
34
|
+
purpose: Mapped[str] = mapped_column(String, nullable=False)
|
|
35
|
+
owner: Mapped[str] = mapped_column(String, nullable=False)
|
|
36
|
+
risk_tier: Mapped[str] = mapped_column(String, nullable=False)
|
|
37
|
+
status: Mapped[str] = mapped_column(String, nullable=False, default="active")
|
|
38
|
+
granted_tools: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
|
39
|
+
created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
40
|
+
updated_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
41
|
+
status_changed_at: Mapped[datetime.datetime | None] = mapped_column(
|
|
42
|
+
DateTime(timezone=True), nullable=True
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ReceiptRow(Base):
|
|
47
|
+
__tablename__ = "execution_receipts"
|
|
48
|
+
|
|
49
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
50
|
+
sequence_id: Mapped[str] = mapped_column(String, nullable=False, unique=True)
|
|
51
|
+
timestamp: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
52
|
+
spiffe_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
|
53
|
+
decision: Mapped[str] = mapped_column(String, nullable=False)
|
|
54
|
+
reason: Mapped[str] = mapped_column(String, nullable=False, default="")
|
|
55
|
+
payload_hash: Mapped[str] = mapped_column(String, nullable=False)
|
|
56
|
+
previous_hash: Mapped[str] = mapped_column(String, nullable=False)
|
|
57
|
+
signature: Mapped[str] = mapped_column(String, nullable=False)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ChainStateRow(Base):
|
|
61
|
+
"""Singleton row (id=1) holding the current chain head. Locked with
|
|
62
|
+
SELECT ... FOR UPDATE inside the same transaction as a receipt insert so
|
|
63
|
+
concurrent writers across replicas serialize instead of racing."""
|
|
64
|
+
|
|
65
|
+
__tablename__ = "chain_state"
|
|
66
|
+
|
|
67
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
68
|
+
previous_hash: Mapped[str] = mapped_column(String, nullable=False)
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Header-trusting identity adapter. See docs/adr/0005.
|
|
2
|
+
|
|
3
|
+
NOT a security boundary on its own: it trusts whatever `X-Spiffe-ID` header is
|
|
4
|
+
present. Only safe behind a network layer (e.g. a service mesh) that has already
|
|
5
|
+
performed real SPIFFE/mTLS attestation. Swap for a SPIRE Workload API adapter before
|
|
6
|
+
this is exposed to anything untrusted.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from openagent_control.domain.errors import IdentityError
|
|
12
|
+
from openagent_control.domain.models import AgentIdentity
|
|
13
|
+
|
|
14
|
+
_SPIFFE_HEADER = "x-spiffe-id"
|
|
15
|
+
_SPONSOR_HEADER = "x-human-sponsor"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HeaderIdentityProvider:
|
|
19
|
+
async def identify(self, raw_headers: dict[str, str]) -> AgentIdentity:
|
|
20
|
+
headers = {k.lower(): v for k, v in raw_headers.items()}
|
|
21
|
+
spiffe_id = headers.get(_SPIFFE_HEADER)
|
|
22
|
+
if not spiffe_id:
|
|
23
|
+
raise IdentityError(f"missing required '{_SPIFFE_HEADER}' header")
|
|
24
|
+
return AgentIdentity(spiffe_id=spiffe_id, human_sponsor=headers.get(_SPONSOR_HEADER))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""JWT-SVID identity adapter: cryptographic workload identity (ADR-0005).
|
|
2
|
+
|
|
3
|
+
Validates a SPIFFE JWT-SVID presented as `Authorization: Bearer <token>`:
|
|
4
|
+
signature against the trust-domain public key, audience, and expiry; the SPIFFE
|
|
5
|
+
ID is the token's `sub` claim. SPIRE issues exactly this token shape via its
|
|
6
|
+
Workload API (`spire-agent api fetch jwt`), so pointing `public_key_path` at the
|
|
7
|
+
trust bundle key makes this the production identity path — unlike the header
|
|
8
|
+
adapter, callers cannot claim an identity they cannot prove.
|
|
9
|
+
|
|
10
|
+
The per-request human sponsor stays a header (`X-Human-Sponsor`): it is an
|
|
11
|
+
assertion about delegation context, verified separately via the subject-token
|
|
12
|
+
exchange (ADR-0004), not part of workload identity.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import jwt
|
|
20
|
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PublicKey
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
23
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
24
|
+
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
|
25
|
+
|
|
26
|
+
from openagent_control.domain.errors import IdentityError
|
|
27
|
+
from openagent_control.domain.models import AgentIdentity
|
|
28
|
+
|
|
29
|
+
_SPONSOR_HEADER = "x-human-sponsor"
|
|
30
|
+
_ALGORITHMS = ["RS256", "ES256", "EdDSA"]
|
|
31
|
+
_SUPPORTED_KEY_TYPES = (RSAPublicKey, EllipticCurvePublicKey, Ed25519PublicKey, Ed448PublicKey)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class JwtSvidIdentityProvider:
|
|
35
|
+
def __init__(self, public_key_path: str | Path, audience: str) -> None:
|
|
36
|
+
# Parse the PEM once at startup: handing PyJWT the raw PEM string makes
|
|
37
|
+
# it re-parse the key on every decode (~56µs/request measured for
|
|
38
|
+
# RSA-2048); a preloaded key object cuts verification ~3x. Rejecting
|
|
39
|
+
# unsupported key types here turns a bad trust-bundle config into a
|
|
40
|
+
# startup failure instead of a per-request one.
|
|
41
|
+
key = load_pem_public_key(Path(public_key_path).read_bytes())
|
|
42
|
+
if not isinstance(key, _SUPPORTED_KEY_TYPES):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"unsupported trust-bundle key type {type(key).__name__}; "
|
|
45
|
+
f"expected one usable with {_ALGORITHMS}"
|
|
46
|
+
)
|
|
47
|
+
self._public_key: (
|
|
48
|
+
RSAPublicKey | EllipticCurvePublicKey | Ed25519PublicKey | Ed448PublicKey
|
|
49
|
+
) = key
|
|
50
|
+
self._audience = audience
|
|
51
|
+
|
|
52
|
+
async def identify(self, raw_headers: dict[str, str]) -> AgentIdentity:
|
|
53
|
+
headers = {k.lower(): v for k, v in raw_headers.items()}
|
|
54
|
+
authorization = headers.get("authorization", "")
|
|
55
|
+
scheme, _, token = authorization.partition(" ")
|
|
56
|
+
if scheme.lower() != "bearer" or not token:
|
|
57
|
+
raise IdentityError("missing 'Authorization: Bearer <jwt-svid>' header")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
claims = jwt.decode(
|
|
61
|
+
token,
|
|
62
|
+
key=self._public_key,
|
|
63
|
+
algorithms=_ALGORITHMS,
|
|
64
|
+
audience=self._audience,
|
|
65
|
+
options={"require": ["sub", "exp", "aud"]},
|
|
66
|
+
)
|
|
67
|
+
except jwt.InvalidTokenError as exc:
|
|
68
|
+
raise IdentityError(f"invalid JWT-SVID: {exc}") from exc
|
|
69
|
+
|
|
70
|
+
spiffe_id = str(claims["sub"])
|
|
71
|
+
if not spiffe_id.startswith("spiffe://"):
|
|
72
|
+
raise IdentityError(f"token subject is not a SPIFFE ID: {spiffe_id!r}")
|
|
73
|
+
|
|
74
|
+
return AgentIdentity(spiffe_id=spiffe_id, human_sponsor=headers.get(_SPONSOR_HEADER))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""OIDC/JWKS identity adapter for Okta and Microsoft Entra ID. See ADR-0010 and
|
|
2
|
+
the `enterprise-idp-integration` skill this was built from.
|
|
3
|
+
|
|
4
|
+
Validates an access token actually issued by an OIDC-compliant IdP: fetches
|
|
5
|
+
the discovery document once at startup, verifies signature/issuer/audience via
|
|
6
|
+
a cached, rotation-aware JWKS client, and derives the calling workload's
|
|
7
|
+
identity from the client/app-id claim (azp/appid/cid) rather than `sub` —
|
|
8
|
+
machine (client-credentials) tokens identify the calling application that way.
|
|
9
|
+
|
|
10
|
+
A `sub` claim is treated as a delegated human sponsor only when the token is
|
|
11
|
+
not an app-only token; see `_has_human_sponsor`, which encodes each provider's
|
|
12
|
+
documented marker. Verified against a real Keycloak realm — see
|
|
13
|
+
tests/integration/test_keycloak_conformance.py.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
import jwt
|
|
22
|
+
from jwt import PyJWKClient
|
|
23
|
+
|
|
24
|
+
from openagent_control.domain.errors import IdentityError
|
|
25
|
+
from openagent_control.domain.models import AgentIdentity
|
|
26
|
+
|
|
27
|
+
_SPONSOR_HEADER = "x-human-sponsor"
|
|
28
|
+
_ALGORITHMS = ["RS256"]
|
|
29
|
+
# Claims that identify the calling application/service principal, checked in
|
|
30
|
+
# order — see ADR-0010 on why this isn't simply `sub`.
|
|
31
|
+
_CLIENT_ID_CLAIMS = ("azp", "appid", "cid")
|
|
32
|
+
|
|
33
|
+
# Keycloak issues client-credentials tokens with `sub` set to the service
|
|
34
|
+
# account's UUID — distinct from the client id — so "sub differs from the
|
|
35
|
+
# client" is not on its own evidence of a human. Each provider marks app-only
|
|
36
|
+
# tokens differently; these are the documented markers:
|
|
37
|
+
_KEYCLOAK_SERVICE_ACCOUNT_PREFIX = "service-account-"
|
|
38
|
+
_ENTRA_APP_ONLY_IDTYP = "app"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _has_human_sponsor(claims: dict[str, object], client_id: str) -> bool:
|
|
42
|
+
"""True only when the token really represents a user acting through the agent.
|
|
43
|
+
|
|
44
|
+
Getting this wrong is not cosmetic: a false positive makes the gateway treat
|
|
45
|
+
an autonomous machine call as delegated and reject it for a missing subject
|
|
46
|
+
token, which is exactly what happened against a real Keycloak realm before
|
|
47
|
+
this check existed.
|
|
48
|
+
"""
|
|
49
|
+
subject = claims.get("sub")
|
|
50
|
+
if not subject or subject == client_id: # Okta client-credentials
|
|
51
|
+
return False
|
|
52
|
+
if claims.get("idtyp") == _ENTRA_APP_ONLY_IDTYP: # Entra app-only token
|
|
53
|
+
return False
|
|
54
|
+
username = claims.get("preferred_username") # Keycloak service account
|
|
55
|
+
return not (isinstance(username, str) and username.startswith(_KEYCLOAK_SERVICE_ACCOUNT_PREFIX))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OidcJwksIdentityProvider:
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
discovery_url: str,
|
|
62
|
+
audience: str,
|
|
63
|
+
issuer: str = "",
|
|
64
|
+
client: httpx.Client | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
# Fetched once at construction (startup), not lazily: a bad discovery
|
|
67
|
+
# URL/document becomes a startup failure, not a per-request one — same
|
|
68
|
+
# posture as the JWT-SVID trust-bundle key (ADR-0005).
|
|
69
|
+
owns_client = client is None
|
|
70
|
+
discovery_client = client or httpx.Client(timeout=10.0)
|
|
71
|
+
try:
|
|
72
|
+
discovery = discovery_client.get(discovery_url)
|
|
73
|
+
discovery.raise_for_status()
|
|
74
|
+
document = discovery.json()
|
|
75
|
+
finally:
|
|
76
|
+
if owns_client:
|
|
77
|
+
discovery_client.close()
|
|
78
|
+
|
|
79
|
+
self._jwks_client = PyJWKClient(document["jwks_uri"])
|
|
80
|
+
self._audience = audience
|
|
81
|
+
self._issuer = issuer or document["issuer"]
|
|
82
|
+
|
|
83
|
+
async def identify(self, raw_headers: dict[str, str]) -> AgentIdentity:
|
|
84
|
+
headers = {k.lower(): v for k, v in raw_headers.items()}
|
|
85
|
+
authorization = headers.get("authorization", "")
|
|
86
|
+
scheme, _, token = authorization.partition(" ")
|
|
87
|
+
if scheme.lower() != "bearer" or not token:
|
|
88
|
+
raise IdentityError("missing 'Authorization: Bearer <access-token>' header")
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
claims = await asyncio.to_thread(self._verify, token)
|
|
92
|
+
except jwt.InvalidTokenError as exc:
|
|
93
|
+
raise IdentityError(f"invalid OIDC access token: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
client_id = next((str(claims[c]) for c in _CLIENT_ID_CLAIMS if c in claims), None)
|
|
96
|
+
if not client_id:
|
|
97
|
+
raise IdentityError(
|
|
98
|
+
f"token has none of the expected client-id claims {_CLIENT_ID_CLAIMS}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
human_sponsor = str(claims["sub"]) if _has_human_sponsor(claims, client_id) else None
|
|
102
|
+
|
|
103
|
+
return AgentIdentity(
|
|
104
|
+
spiffe_id=f"oidc://{self._issuer}/{client_id}",
|
|
105
|
+
human_sponsor=human_sponsor or headers.get(_SPONSOR_HEADER),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def _verify(self, token: str) -> dict[str, object]:
|
|
109
|
+
# Runs in a worker thread (see identify()): PyJWKClient performs a
|
|
110
|
+
# blocking HTTP call on a JWKS cache miss (key rotation), which would
|
|
111
|
+
# otherwise stall the event loop for every concurrent request.
|
|
112
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
113
|
+
claims: dict[str, object] = jwt.decode(
|
|
114
|
+
token,
|
|
115
|
+
key=signing_key.key,
|
|
116
|
+
algorithms=_ALGORITHMS,
|
|
117
|
+
audience=self._audience,
|
|
118
|
+
issuer=self._issuer,
|
|
119
|
+
options={"require": ["iss", "aud", "exp"]},
|
|
120
|
+
)
|
|
121
|
+
return claims
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""In-memory Ed25519 hash-chained ledger adapter. See docs/adr/0003.
|
|
2
|
+
|
|
3
|
+
Known v1 limitations (tracked in the ADR, not solved here — PostgresLedger in
|
|
4
|
+
adapters/ledger/postgres.py addresses both per ADR-0009):
|
|
5
|
+
- the signing key is generated in-process and lost on restart;
|
|
6
|
+
- chain state (`_previous_hash`) lives in a single process's memory, so this does
|
|
7
|
+
not scale to multiple replicas without a shared, race-safe store.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
17
|
+
|
|
18
|
+
from openagent_control.adapters.ledger.signing import GENESIS_HASH, ReceiptSigner, canonical_json
|
|
19
|
+
from openagent_control.domain.models import (
|
|
20
|
+
AgentIdentity,
|
|
21
|
+
ExecutionReceipt,
|
|
22
|
+
PolicyDecision,
|
|
23
|
+
ToolCallRequest,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Ed25519ChainLedger:
|
|
28
|
+
def __init__(self, private_key: Ed25519PrivateKey | None = None) -> None:
|
|
29
|
+
self._signer = ReceiptSigner(private_key)
|
|
30
|
+
self._previous_hash = GENESIS_HASH
|
|
31
|
+
self._lock = asyncio.Lock()
|
|
32
|
+
|
|
33
|
+
def public_key(self) -> Ed25519PublicKey:
|
|
34
|
+
"""The verification key for receipts signed by this ledger instance."""
|
|
35
|
+
return self._signer.public_key()
|
|
36
|
+
|
|
37
|
+
async def record(
|
|
38
|
+
self, agent: AgentIdentity, request: ToolCallRequest, decision: PolicyDecision
|
|
39
|
+
) -> ExecutionReceipt:
|
|
40
|
+
payload_hash = hashlib.sha256(canonical_json(request.model_dump(mode="json"))).hexdigest()
|
|
41
|
+
|
|
42
|
+
async with self._lock:
|
|
43
|
+
receipt = ExecutionReceipt(
|
|
44
|
+
sequence_id=str(uuid.uuid4()),
|
|
45
|
+
spiffe_id=agent.spiffe_id,
|
|
46
|
+
decision=decision.decision,
|
|
47
|
+
reason=decision.reason,
|
|
48
|
+
payload_hash=payload_hash,
|
|
49
|
+
previous_hash=self._previous_hash,
|
|
50
|
+
)
|
|
51
|
+
unsigned_bytes = canonical_json(receipt.model_dump(mode="json", exclude={"signature"}))
|
|
52
|
+
signature = self._signer.sign(unsigned_bytes)
|
|
53
|
+
receipt.signature = signature.hex()
|
|
54
|
+
|
|
55
|
+
self._previous_hash = hashlib.sha256(unsigned_bytes + signature).hexdigest()
|
|
56
|
+
|
|
57
|
+
return receipt
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Postgres-backed hash-chained ledger. See docs/adr/0009.
|
|
2
|
+
|
|
3
|
+
Closes both gaps ADR-0003 flagged: the signing key is provided by the caller
|
|
4
|
+
(sourced from wherever the operator's KMS/secret store puts it — this adapter
|
|
5
|
+
does not generate or persist it), and chain state is a database row, correct
|
|
6
|
+
across multiple gateway replicas because `record()` takes a row lock on the
|
|
7
|
+
chain head inside the same transaction as the receipt insert.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
16
|
+
from sqlalchemy import select
|
|
17
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
18
|
+
|
|
19
|
+
from openagent_control.adapters.db.tables import ChainStateRow, ReceiptRow
|
|
20
|
+
from openagent_control.adapters.ledger.signing import ReceiptSigner, canonical_json
|
|
21
|
+
from openagent_control.domain.models import (
|
|
22
|
+
AgentIdentity,
|
|
23
|
+
ExecutionReceipt,
|
|
24
|
+
PolicyDecision,
|
|
25
|
+
ToolCallRequest,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PostgresLedger:
|
|
30
|
+
def __init__(
|
|
31
|
+
self, session_factory: async_sessionmaker[AsyncSession], signer: ReceiptSigner
|
|
32
|
+
) -> None:
|
|
33
|
+
self._session_factory = session_factory
|
|
34
|
+
self._signer = signer
|
|
35
|
+
|
|
36
|
+
def public_key(self) -> Ed25519PublicKey:
|
|
37
|
+
return self._signer.public_key()
|
|
38
|
+
|
|
39
|
+
async def record(
|
|
40
|
+
self, agent: AgentIdentity, request: ToolCallRequest, decision: PolicyDecision
|
|
41
|
+
) -> ExecutionReceipt:
|
|
42
|
+
payload_hash = hashlib.sha256(canonical_json(request.model_dump(mode="json"))).hexdigest()
|
|
43
|
+
|
|
44
|
+
async with self._session_factory() as session, session.begin():
|
|
45
|
+
chain_state = (
|
|
46
|
+
await session.execute(
|
|
47
|
+
select(ChainStateRow).where(ChainStateRow.id == 1).with_for_update()
|
|
48
|
+
)
|
|
49
|
+
).scalar_one()
|
|
50
|
+
|
|
51
|
+
receipt = ExecutionReceipt(
|
|
52
|
+
sequence_id=str(uuid.uuid4()),
|
|
53
|
+
spiffe_id=agent.spiffe_id,
|
|
54
|
+
decision=decision.decision,
|
|
55
|
+
reason=decision.reason,
|
|
56
|
+
payload_hash=payload_hash,
|
|
57
|
+
previous_hash=chain_state.previous_hash,
|
|
58
|
+
)
|
|
59
|
+
unsigned_bytes = canonical_json(receipt.model_dump(mode="json", exclude={"signature"}))
|
|
60
|
+
signature = self._signer.sign(unsigned_bytes)
|
|
61
|
+
receipt.signature = signature.hex()
|
|
62
|
+
|
|
63
|
+
session.add(
|
|
64
|
+
ReceiptRow(
|
|
65
|
+
sequence_id=receipt.sequence_id,
|
|
66
|
+
timestamp=receipt.timestamp,
|
|
67
|
+
spiffe_id=receipt.spiffe_id,
|
|
68
|
+
decision=receipt.decision.value,
|
|
69
|
+
reason=receipt.reason,
|
|
70
|
+
payload_hash=receipt.payload_hash,
|
|
71
|
+
previous_hash=receipt.previous_hash,
|
|
72
|
+
signature=receipt.signature,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
chain_state.previous_hash = hashlib.sha256(unsigned_bytes + signature).hexdigest()
|
|
76
|
+
|
|
77
|
+
return receipt
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Shared Ed25519 receipt-signing logic used by every ledger adapter.
|
|
2
|
+
|
|
3
|
+
Extracted so the in-memory (Ed25519ChainLedger) and Postgres (PostgresLedger)
|
|
4
|
+
adapters sign identically instead of duplicating canonical-JSON/crypto code —
|
|
5
|
+
see ADR-0009.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
14
|
+
|
|
15
|
+
GENESIS_HASH = "0" * 64
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def canonical_json(data: dict[str, Any]) -> bytes:
|
|
19
|
+
return json.dumps(data, sort_keys=True, default=str).encode("utf-8")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ReceiptSigner:
|
|
23
|
+
def __init__(self, private_key: Ed25519PrivateKey | None = None) -> None:
|
|
24
|
+
self._private_key = private_key or Ed25519PrivateKey.generate()
|
|
25
|
+
|
|
26
|
+
def sign(self, unsigned_bytes: bytes) -> bytes:
|
|
27
|
+
return self._private_key.sign(unsigned_bytes)
|
|
28
|
+
|
|
29
|
+
def public_key(self) -> Ed25519PublicKey:
|
|
30
|
+
return self._private_key.public_key()
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""HTTP MCP upstream adapter — forwards an approved tools/call to a downstream MCP server.
|
|
2
|
+
|
|
3
|
+
Real target adapters (Intapp DealCloud, other MCP servers) point this same
|
|
4
|
+
implementation at a different `upstream_url`; no new adapter code is needed unless a
|
|
5
|
+
target requires a non-standard request shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from openagent_control.domain.errors import UpstreamError
|
|
15
|
+
from openagent_control.domain.models import ToolCallRequest
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HttpMCPUpstream:
|
|
19
|
+
def __init__(self, upstream_url: str, client: httpx.AsyncClient | None = None) -> None:
|
|
20
|
+
self._upstream_url = upstream_url
|
|
21
|
+
self._client = client or httpx.AsyncClient(timeout=10.0)
|
|
22
|
+
|
|
23
|
+
async def forward(self, request: ToolCallRequest, credential: str) -> dict[str, Any]:
|
|
24
|
+
payload = {
|
|
25
|
+
"jsonrpc": "2.0",
|
|
26
|
+
"id": request.request_id,
|
|
27
|
+
"method": request.method,
|
|
28
|
+
"params": {"name": request.tool_name, "arguments": request.arguments},
|
|
29
|
+
}
|
|
30
|
+
headers = {"Authorization": f"Bearer {credential}"}
|
|
31
|
+
try:
|
|
32
|
+
response = await self._client.post(self._upstream_url, json=payload, headers=headers)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
except httpx.HTTPError as exc:
|
|
35
|
+
raise UpstreamError(str(exc)) from exc
|
|
36
|
+
result: dict[str, Any] = response.json()
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
async def aclose(self) -> None:
|
|
40
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""MCP Streamable HTTP upstream adapter, built on the official MCP Python SDK.
|
|
2
|
+
|
|
3
|
+
`HttpMCPUpstream` POSTs a bare JSON-RPC body, which is *not* the MCP transport.
|
|
4
|
+
A real MCP server rejects that with `406 Not Acceptable` (the client must accept
|
|
5
|
+
both `application/json` and `text/event-stream`) and then `400 Missing session
|
|
6
|
+
ID` (Streamable HTTP requires an `initialize` handshake first). Rather than
|
|
7
|
+
re-implement handshake, session management and SSE framing here, this adapter
|
|
8
|
+
delegates all of it to `mcp.ClientSession` + `mcp.client.streamable_http` — the
|
|
9
|
+
reference implementation maintained alongside the specification.
|
|
10
|
+
|
|
11
|
+
Credential handling follows the MCP authorization spec (2025-06-18): the
|
|
12
|
+
brokered credential is sent as `Authorization: Bearer`, and it is a token the
|
|
13
|
+
gateway obtained for the *upstream's own* audience — never the agent's token.
|
|
14
|
+
The spec explicitly forbids token passthrough, and audience-scoped brokering
|
|
15
|
+
(ADR-0004) is precisely what satisfies that requirement.
|
|
16
|
+
|
|
17
|
+
Session lifecycle: one MCP session per tool call. That costs an extra
|
|
18
|
+
initialize round trip compared with pooling sessions, and is a deliberate v1
|
|
19
|
+
tradeoff — a pooled session is stateful, must be rebuilt when the upstream
|
|
20
|
+
restarts, and needs its own concurrency control. See ADR-0011.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from mcp import ClientSession
|
|
29
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
30
|
+
|
|
31
|
+
from openagent_control.domain.errors import UpstreamError
|
|
32
|
+
from openagent_control.domain.models import ToolCallRequest
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StreamableHttpMCPUpstream:
|
|
36
|
+
def __init__(self, upstream_url: str, timeout: float = 30.0) -> None:
|
|
37
|
+
self._upstream_url = upstream_url
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
|
|
40
|
+
async def forward(self, request: ToolCallRequest, credential: str) -> dict[str, Any]:
|
|
41
|
+
headers = {"Authorization": f"Bearer {credential}"}
|
|
42
|
+
try:
|
|
43
|
+
async with (
|
|
44
|
+
httpx.AsyncClient(headers=headers, timeout=self._timeout) as http_client,
|
|
45
|
+
streamable_http_client(self._upstream_url, http_client=http_client) as streams,
|
|
46
|
+
ClientSession(streams[0], streams[1]) as session,
|
|
47
|
+
):
|
|
48
|
+
await session.initialize()
|
|
49
|
+
return await self._dispatch(session, request)
|
|
50
|
+
# No `except UpstreamError: raise` here: an UpstreamError raised inside
|
|
51
|
+
# the session is re-wrapped by anyio's task group before it reaches this
|
|
52
|
+
# frame, so that branch would be unreachable. `_describe` unwraps it.
|
|
53
|
+
except Exception as exc: # noqa: BLE001 — SDK surfaces many error types
|
|
54
|
+
raise UpstreamError(_describe(exc)) from exc
|
|
55
|
+
|
|
56
|
+
async def _dispatch(self, session: ClientSession, request: ToolCallRequest) -> dict[str, Any]:
|
|
57
|
+
if request.method == "tools/list":
|
|
58
|
+
listing = await session.list_tools()
|
|
59
|
+
return _jsonrpc_result(request, listing.model_dump(mode="json"))
|
|
60
|
+
|
|
61
|
+
result = await session.call_tool(request.tool_name or "", request.arguments or {})
|
|
62
|
+
payload = result.model_dump(mode="json")
|
|
63
|
+
if result.isError:
|
|
64
|
+
raise UpstreamError(_error_text(payload))
|
|
65
|
+
return _jsonrpc_result(request, payload)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _describe(exc: BaseException) -> str:
|
|
69
|
+
"""Renders an SDK failure into something an agent can act on.
|
|
70
|
+
|
|
71
|
+
The SDK runs the session in an anyio task group, so failures surface as
|
|
72
|
+
`ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` — which
|
|
73
|
+
tells the agent (and the on-call engineer) nothing. Unwrap to the real
|
|
74
|
+
causes, since this message is what the denial payload carries back.
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
77
|
+
return "; ".join(_describe(inner) for inner in exc.exceptions)
|
|
78
|
+
# Our own error raised inside the session's task group gets wrapped on the
|
|
79
|
+
# way out; don't re-label it with its own class name.
|
|
80
|
+
if isinstance(exc, UpstreamError):
|
|
81
|
+
return str(exc)
|
|
82
|
+
return f"{type(exc).__name__}: {exc}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _jsonrpc_result(request: ToolCallRequest, payload: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
return {"jsonrpc": "2.0", "id": request.request_id, "result": payload}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _error_text(payload: dict[str, Any]) -> str:
|
|
90
|
+
"""Flattens an MCP error result's content blocks into one message."""
|
|
91
|
+
blocks = payload.get("content") or []
|
|
92
|
+
texts = [b.get("text", "") for b in blocks if isinstance(b, dict) and b.get("type") == "text"]
|
|
93
|
+
return "; ".join(t for t in texts if t) or "upstream reported an error"
|
|
File without changes
|