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.
Files changed (56) hide show
  1. openagent_control/__init__.py +0 -0
  2. openagent_control/adapters/__init__.py +0 -0
  3. openagent_control/adapters/audit_export/__init__.py +0 -0
  4. openagent_control/adapters/audit_export/stdout.py +36 -0
  5. openagent_control/adapters/db/__init__.py +0 -0
  6. openagent_control/adapters/db/session.py +26 -0
  7. openagent_control/adapters/db/tables.py +68 -0
  8. openagent_control/adapters/identity/__init__.py +0 -0
  9. openagent_control/adapters/identity/header.py +24 -0
  10. openagent_control/adapters/identity/jwt_svid.py +74 -0
  11. openagent_control/adapters/identity/oidc_jwks.py +121 -0
  12. openagent_control/adapters/ledger/__init__.py +0 -0
  13. openagent_control/adapters/ledger/ed25519_chain.py +57 -0
  14. openagent_control/adapters/ledger/postgres.py +77 -0
  15. openagent_control/adapters/ledger/signing.py +30 -0
  16. openagent_control/adapters/mcp_upstream/__init__.py +0 -0
  17. openagent_control/adapters/mcp_upstream/http.py +40 -0
  18. openagent_control/adapters/mcp_upstream/streamable_http.py +93 -0
  19. openagent_control/adapters/policy/__init__.py +0 -0
  20. openagent_control/adapters/policy/opa.py +46 -0
  21. openagent_control/adapters/registry/__init__.py +0 -0
  22. openagent_control/adapters/registry/caching.py +34 -0
  23. openagent_control/adapters/registry/file.py +38 -0
  24. openagent_control/adapters/registry/postgres.py +42 -0
  25. openagent_control/adapters/token_exchange/__init__.py +0 -0
  26. openagent_control/adapters/token_exchange/caching.py +73 -0
  27. openagent_control/adapters/token_exchange/entra_obo.py +52 -0
  28. openagent_control/adapters/token_exchange/rfc8693.py +50 -0
  29. openagent_control/adapters/token_exchange/stub.py +12 -0
  30. openagent_control/alembic.ini +36 -0
  31. openagent_control/application/__init__.py +0 -0
  32. openagent_control/application/governed_execution.py +177 -0
  33. openagent_control/cli.py +162 -0
  34. openagent_control/config.py +67 -0
  35. openagent_control/diagnostics.py +120 -0
  36. openagent_control/domain/__init__.py +0 -0
  37. openagent_control/domain/errors.py +31 -0
  38. openagent_control/domain/models.py +93 -0
  39. openagent_control/domain/ports.py +103 -0
  40. openagent_control/gateway/__init__.py +0 -0
  41. openagent_control/gateway/app.py +51 -0
  42. openagent_control/gateway/dependencies.py +212 -0
  43. openagent_control/gateway/routes/__init__.py +0 -0
  44. openagent_control/gateway/routes/mcp.py +51 -0
  45. openagent_control/migrations/env.py +52 -0
  46. openagent_control/migrations/script.py.mako +27 -0
  47. openagent_control/migrations/versions/0001_initial_schema.py +82 -0
  48. openagent_control/py.typed +0 -0
  49. openagent_control/resources/__init__.py +72 -0
  50. openagent_control/resources/agents.example.yaml +32 -0
  51. openagent_control/resources/policies/mcp_authz.rego +47 -0
  52. openagent_control-0.1.0.dist-info/METADATA +241 -0
  53. openagent_control-0.1.0.dist-info/RECORD +56 -0
  54. openagent_control-0.1.0.dist-info/WHEEL +4 -0
  55. openagent_control-0.1.0.dist-info/entry_points.txt +3 -0
  56. 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