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
@@ -0,0 +1,46 @@
1
+ """OPA/Rego policy engine adapter. See docs/adr/0002-opa-rego-as-the-v1-policy-engine.md."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from openagent_control.domain.errors import PolicyEngineUnavailableError
8
+ from openagent_control.domain.models import Decision, PolicyDecision, ToolCallRequest
9
+
10
+
11
+ class OPAPolicyEngine:
12
+ """Evaluates a tool call against OPA's HTTP API."""
13
+
14
+ def __init__(self, opa_url: str, client: httpx.AsyncClient | None = None) -> None:
15
+ self._opa_url = opa_url
16
+ self._client = client or httpx.AsyncClient(timeout=5.0)
17
+
18
+ async def evaluate(self, request: ToolCallRequest) -> PolicyDecision:
19
+ opa_input = {
20
+ "input": {
21
+ "method": request.method,
22
+ "spiffe_id": request.agent.spiffe_id,
23
+ # Registry facts (ADR-0008): policy logic evaluates against these
24
+ # instead of data hardcoded in the Rego file.
25
+ "agent": (
26
+ request.registration.model_dump(mode="json") if request.registration else None
27
+ ),
28
+ "params": {
29
+ "name": request.tool_name,
30
+ "arguments": request.arguments,
31
+ },
32
+ }
33
+ }
34
+ try:
35
+ response = await self._client.post(self._opa_url, json=opa_input)
36
+ response.raise_for_status()
37
+ except httpx.HTTPError as exc:
38
+ raise PolicyEngineUnavailableError(str(exc)) from exc
39
+ result = response.json().get("result", {})
40
+
41
+ decision = Decision.ALLOW if result.get("allow", False) else Decision.DENY
42
+ reason = result.get("reason", "" if decision is Decision.ALLOW else "Denied by policy")
43
+ return PolicyDecision(decision=decision, reason=reason)
44
+
45
+ async def aclose(self) -> None:
46
+ await self._client.aclose()
File without changes
@@ -0,0 +1,34 @@
1
+ """Redis-caching decorator over any AgentRegistry. See docs/adr/0009.
2
+
3
+ Wraps — does not replace — the file or Postgres registry, so caching composes
4
+ with either backend unchanged. Short TTL (default 30s) bounds how stale a status
5
+ read can be; there is no invalidate-on-write yet, which is a deliberate,
6
+ documented trade-off (see the ADR) until an admin/kill-switch surface exists.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from redis.asyncio import Redis
12
+
13
+ from openagent_control.domain.models import RegisteredAgent
14
+ from openagent_control.domain.ports import AgentRegistry
15
+
16
+ _KEY_PREFIX = "oac:registry:"
17
+
18
+
19
+ class CachingAgentRegistry:
20
+ def __init__(self, inner: AgentRegistry, redis: Redis, ttl_seconds: int) -> None:
21
+ self._inner = inner
22
+ self._redis = redis
23
+ self._ttl_seconds = ttl_seconds
24
+
25
+ async def lookup(self, spiffe_id: str) -> RegisteredAgent | None:
26
+ key = _KEY_PREFIX + spiffe_id
27
+ cached = await self._redis.get(key)
28
+ if cached is not None:
29
+ # Empty string is the cached-negative marker (agent confirmed absent).
30
+ return RegisteredAgent.model_validate_json(cached) if cached else None
31
+
32
+ agent = await self._inner.lookup(spiffe_id)
33
+ await self._redis.set(key, agent.model_dump_json() if agent else "", ex=self._ttl_seconds)
34
+ return agent
@@ -0,0 +1,38 @@
1
+ """File-backed agent registry. See docs/adr/0008-agent-registry-as-source-of-truth.md.
2
+
3
+ Config-as-code: agents are registered by adding a record to a YAML file reviewed
4
+ through normal git workflow. A database- or IdP-backed registry is a later
5
+ adapter behind the same `AgentRegistry` port.
6
+
7
+ The parsed file is cached but re-read when its mtime changes. Caching it for the
8
+ process lifetime would mean suspending an agent had no effect until the gateway
9
+ restarted — revocation is a control-plane guarantee (ADR-0008), so it cannot
10
+ depend on a redeploy. The cost is one `stat` per lookup.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+
19
+ from openagent_control.domain.models import RegisteredAgent
20
+
21
+
22
+ class FileAgentRegistry:
23
+ def __init__(self, path: str | Path) -> None:
24
+ self._path = Path(path)
25
+ self._agents: dict[str, RegisteredAgent] | None = None
26
+ self._loaded_mtime: float | None = None
27
+
28
+ def _load(self) -> dict[str, RegisteredAgent]:
29
+ mtime = self._path.stat().st_mtime
30
+ if self._agents is None or mtime != self._loaded_mtime:
31
+ raw = yaml.safe_load(self._path.read_text()) or {}
32
+ agents = [RegisteredAgent.model_validate(entry) for entry in raw.get("agents", [])]
33
+ self._agents = {agent.spiffe_id: agent for agent in agents}
34
+ self._loaded_mtime = mtime
35
+ return self._agents
36
+
37
+ async def lookup(self, spiffe_id: str) -> RegisteredAgent | None:
38
+ return self._load().get(spiffe_id)
@@ -0,0 +1,42 @@
1
+ """Postgres-backed Agent Registry. See docs/adr/0008 and docs/adr/0009.
2
+
3
+ The production registry adapter: agent facts live as queryable rows in `oac.agents`
4
+ rather than a git-reviewed file, so status is inventoried and (with the caching
5
+ layer's short TTL, see adapters/registry/caching.py) close to instantly readable
6
+ after a change — the capability an admin kill-switch feature would build on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sqlalchemy import select
12
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
13
+
14
+ from openagent_control.adapters.db.tables import AgentRow
15
+ from openagent_control.domain.models import AgentStatus, RegisteredAgent, RiskTier
16
+
17
+
18
+ def _to_domain(row: AgentRow) -> RegisteredAgent:
19
+ return RegisteredAgent(
20
+ spiffe_id=row.spiffe_id,
21
+ display_name=row.display_name,
22
+ purpose=row.purpose,
23
+ owner=row.owner,
24
+ risk_tier=RiskTier(row.risk_tier),
25
+ status=AgentStatus(row.status),
26
+ granted_tools=list(row.granted_tools),
27
+ created_at=row.created_at,
28
+ updated_at=row.updated_at,
29
+ status_changed_at=row.status_changed_at,
30
+ )
31
+
32
+
33
+ class PostgresAgentRegistry:
34
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
35
+ self._session_factory = session_factory
36
+
37
+ async def lookup(self, spiffe_id: str) -> RegisteredAgent | None:
38
+ async with self._session_factory() as session:
39
+ row = (
40
+ await session.execute(select(AgentRow).where(AgentRow.spiffe_id == spiffe_id))
41
+ ).scalar_one_or_none()
42
+ return _to_domain(row) if row is not None else None
File without changes
@@ -0,0 +1,73 @@
1
+ """Redis-caching decorator over any TokenExchange. See docs/adr/0009.
2
+
3
+ Cache TTL is derived from the exchanged token's own `exp` claim (peeked via an
4
+ unverified JWT decode — we already trust this token because it came straight
5
+ from the IdP's response over TLS; we are not re-validating its signature, only
6
+ reading a cache-lifetime hint), minus a safety margin, capped at a configured
7
+ maximum. Opaque (non-JWT) tokens fall back to the safety-margin value as a short,
8
+ conservative TTL. Never caches past a token's real expiry.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import time
15
+
16
+ import jwt
17
+ from redis.asyncio import Redis
18
+
19
+ from openagent_control.domain.ports import TokenExchange
20
+
21
+ _KEY_PREFIX = "oac:token-exchange:"
22
+
23
+
24
+ def _cache_key(subject_token: str, audience: str) -> str:
25
+ digest = hashlib.sha256(f"{subject_token}:{audience}".encode()).hexdigest()
26
+ return _KEY_PREFIX + digest
27
+
28
+
29
+ def _ttl_from_token(token: str, max_ttl: int, safety_margin: int) -> int:
30
+ try:
31
+ claims = jwt.decode(token, options={"verify_signature": False})
32
+ exp = int(claims["exp"])
33
+ except (jwt.InvalidTokenError, KeyError, TypeError, ValueError):
34
+ return safety_margin
35
+
36
+ remaining = exp - int(time.time()) - safety_margin
37
+ return max(0, min(remaining, max_ttl))
38
+
39
+
40
+ class CachingTokenExchange:
41
+ def __init__(
42
+ self,
43
+ inner: TokenExchange,
44
+ redis: Redis,
45
+ max_ttl_seconds: int,
46
+ safety_margin_seconds: int,
47
+ ) -> None:
48
+ self._inner = inner
49
+ self._redis = redis
50
+ self._max_ttl_seconds = max_ttl_seconds
51
+ self._safety_margin_seconds = safety_margin_seconds
52
+
53
+ async def exchange(self, subject_token: str, audience: str) -> str:
54
+ key = _cache_key(subject_token, audience)
55
+ cached = await self._redis.get(key)
56
+ if cached is not None:
57
+ # A client without decode_responses=True hands back bytes; str() on
58
+ # bytes would corrupt the credential into "b'...'", so decode
59
+ # explicitly instead of coercing.
60
+ return cached.decode() if isinstance(cached, bytes) else str(cached)
61
+
62
+ token = await self._inner.exchange(subject_token, audience)
63
+ ttl = _ttl_from_token(token, self._max_ttl_seconds, self._safety_margin_seconds)
64
+ if ttl > 0:
65
+ await self._redis.set(key, token, ex=ttl)
66
+ return token
67
+
68
+ async def aclose(self) -> None:
69
+ """Forwards to the wrapped adapter; the decorator owns no resources of
70
+ its own (the Redis client is shared and closed once by the Container)."""
71
+ closer = getattr(self._inner, "aclose", None)
72
+ if closer is not None:
73
+ await closer()
@@ -0,0 +1,52 @@
1
+ """Microsoft Entra ID On-Behalf-Of token exchange adapter.
2
+
3
+ Entra's OBO flow predates RFC 8693 and uses the jwt-bearer grant with
4
+ `requested_token_use=on_behalf_of`. The `audience` argument of the port maps to
5
+ Entra's `scope` parameter (callers typically pass `api://<app-id>/.default`).
6
+ See ADR-0004.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+ from openagent_control.domain.errors import TokenExchangeError
14
+
15
+ _GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
16
+
17
+
18
+ class EntraOnBehalfOfTokenExchange:
19
+ def __init__(
20
+ self,
21
+ token_url: str,
22
+ client_id: str,
23
+ client_secret: str,
24
+ client: httpx.AsyncClient | None = None,
25
+ ) -> None:
26
+ self._token_url = token_url
27
+ self._client_id = client_id
28
+ self._client_secret = client_secret
29
+ self._client = client or httpx.AsyncClient(timeout=10.0)
30
+
31
+ async def exchange(self, subject_token: str, audience: str) -> str:
32
+ data = {
33
+ "grant_type": _GRANT_TYPE,
34
+ "client_id": self._client_id,
35
+ "client_secret": self._client_secret,
36
+ "assertion": subject_token,
37
+ "scope": audience,
38
+ "requested_token_use": "on_behalf_of",
39
+ }
40
+ try:
41
+ response = await self._client.post(self._token_url, data=data)
42
+ response.raise_for_status()
43
+ except httpx.HTTPError as exc:
44
+ raise TokenExchangeError(str(exc)) from exc
45
+
46
+ access_token = response.json().get("access_token")
47
+ if not access_token:
48
+ raise TokenExchangeError("Entra returned no access_token")
49
+ return str(access_token)
50
+
51
+ async def aclose(self) -> None:
52
+ await self._client.aclose()
@@ -0,0 +1,50 @@
1
+ """RFC 8693 OAuth 2.0 Token Exchange adapter (Okta-compatible).
2
+
3
+ Swaps the human sponsor's subject token for a short-lived, audience-scoped access
4
+ token. Works against any authorization server implementing RFC 8693 (Okta org
5
+ authorization servers, Keycloak, etc.). See ADR-0004.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+ from openagent_control.domain.errors import TokenExchangeError
13
+
14
+ _GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
15
+ _ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
16
+
17
+
18
+ class Rfc8693TokenExchange:
19
+ def __init__(
20
+ self,
21
+ token_url: str,
22
+ client_id: str,
23
+ client_secret: str,
24
+ client: httpx.AsyncClient | None = None,
25
+ ) -> None:
26
+ self._token_url = token_url
27
+ self._auth = (client_id, client_secret)
28
+ self._client = client or httpx.AsyncClient(timeout=10.0)
29
+
30
+ async def exchange(self, subject_token: str, audience: str) -> str:
31
+ data = {
32
+ "grant_type": _GRANT_TYPE,
33
+ "subject_token": subject_token,
34
+ "subject_token_type": _ACCESS_TOKEN_TYPE,
35
+ "requested_token_type": _ACCESS_TOKEN_TYPE,
36
+ "audience": audience,
37
+ }
38
+ try:
39
+ response = await self._client.post(self._token_url, data=data, auth=self._auth)
40
+ response.raise_for_status()
41
+ except httpx.HTTPError as exc:
42
+ raise TokenExchangeError(str(exc)) from exc
43
+
44
+ access_token = response.json().get("access_token")
45
+ if not access_token:
46
+ raise TokenExchangeError("authorization server returned no access_token")
47
+ return str(access_token)
48
+
49
+ async def aclose(self) -> None:
50
+ await self._client.aclose()
@@ -0,0 +1,12 @@
1
+ """Stub RFC 8693 token exchange. See docs/adr/0004-mcp-as-the-v1-protocol-surface.md.
2
+
3
+ Real adapters (Okta, Microsoft Entra ID) implement the same `TokenExchange` port —
4
+ see docs/adr/0006-hexagonal-architecture-for-the-control-plane.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ class StubTokenExchange:
11
+ async def exchange(self, subject_token: str, audience: str) -> str:
12
+ return f"stub-obo-token::aud={audience}::subj={subject_token[:8]}"
@@ -0,0 +1,36 @@
1
+ [alembic]
2
+ script_location = migrations
3
+ prepend_sys_path = .
4
+ path_separator = os
5
+
6
+ [loggers]
7
+ keys = root,sqlalchemy,alembic
8
+
9
+ [handlers]
10
+ keys = console
11
+
12
+ [formatters]
13
+ keys = generic
14
+
15
+ [logger_root]
16
+ level = WARNING
17
+ handlers = console
18
+
19
+ [logger_sqlalchemy]
20
+ level = WARNING
21
+ handlers =
22
+ qualname = sqlalchemy.engine
23
+
24
+ [logger_alembic]
25
+ level = INFO
26
+ handlers =
27
+ qualname = alembic
28
+
29
+ [handler_console]
30
+ class = StreamHandler
31
+ args = (sys.stderr,)
32
+ level = NOTSET
33
+ formatter = generic
34
+
35
+ [formatter_generic]
36
+ format = %(levelname)-5.5s [%(name)s] %(message)s
File without changes
@@ -0,0 +1,177 @@
1
+ """The governed-execution use case: the transport-agnostic core of the gateway.
2
+
3
+ Every deployment pattern from ADR-0001 (egress gateway today; sidecar and native SDK
4
+ later) drives this same service rather than re-implementing the sequence:
5
+
6
+ identify -> evaluate policy -> record + export receipt
7
+ -> (deny: semantic error) | (allow: broker credential, forward upstream)
8
+
9
+ Failure posture:
10
+ - Policy engine unreachable => fail closed: an explicit DENY is recorded and
11
+ exported, so the outage itself leaves an audit trail (never a silent 500).
12
+ - Upstream failure after ALLOW => JSON-RPC error with a stop instruction, so an LLM
13
+ agent halts instead of retry-looping; the ALLOW receipt has already been recorded.
14
+ - Identity / missing subject token => typed errors for the transport layer to map
15
+ (HTTP 401 in the FastAPI adapter).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from openagent_control.domain.errors import (
23
+ MissingSubjectTokenError,
24
+ PolicyEngineUnavailableError,
25
+ TokenExchangeError,
26
+ UpstreamError,
27
+ )
28
+ from openagent_control.domain.models import (
29
+ AgentIdentity,
30
+ AgentStatus,
31
+ Decision,
32
+ PolicyDecision,
33
+ ToolCallRequest,
34
+ )
35
+ from openagent_control.domain.ports import (
36
+ AgentRegistry,
37
+ AuditExporter,
38
+ IdentityProvider,
39
+ Ledger,
40
+ MCPUpstream,
41
+ PolicyEngine,
42
+ TokenExchange,
43
+ )
44
+
45
+ _SUBJECT_TOKEN_HEADER = "x-subject-token"
46
+
47
+ _STOP_INSTRUCTION = "Stop execution and request user approval."
48
+ _FAIL_CLOSED_REASON = "Policy engine unavailable; denied (fail-closed)"
49
+
50
+
51
+ class GovernedExecutionService:
52
+ def __init__(
53
+ self,
54
+ *,
55
+ identity_provider: IdentityProvider,
56
+ agent_registry: AgentRegistry,
57
+ policy_engine: PolicyEngine,
58
+ ledger: Ledger,
59
+ audit_exporter: AuditExporter,
60
+ token_exchange: TokenExchange,
61
+ mcp_upstream: MCPUpstream,
62
+ delegated_audience: str,
63
+ ) -> None:
64
+ self._identity_provider = identity_provider
65
+ self._agent_registry = agent_registry
66
+ self._policy_engine = policy_engine
67
+ self._ledger = ledger
68
+ self._audit_exporter = audit_exporter
69
+ self._token_exchange = token_exchange
70
+ self._mcp_upstream = mcp_upstream
71
+ self._delegated_audience = delegated_audience
72
+
73
+ async def execute(self, headers: dict[str, str], payload: dict[str, Any]) -> dict[str, Any]:
74
+ """Runs one governed tool call; returns a JSON-RPC response object.
75
+
76
+ Raises IdentityError or MissingSubjectTokenError for the transport layer
77
+ to map to its own auth failure shape.
78
+ """
79
+ agent = await self._identity_provider.identify(headers)
80
+ registration = await self._agent_registry.lookup(agent.spiffe_id)
81
+
82
+ params = payload.get("params") or {}
83
+ call = ToolCallRequest(
84
+ method=payload.get("method", ""),
85
+ tool_name=params.get("name"),
86
+ arguments=params.get("arguments", {}),
87
+ agent=agent,
88
+ registration=registration,
89
+ request_id=payload.get("id"),
90
+ )
91
+
92
+ # Registry gate (ADR-0008): orphaned or suspended agents never reach the
93
+ # policy engine, but the attempt is still receipted below.
94
+ if registration is None:
95
+ decision = PolicyDecision(
96
+ decision=Decision.DENY,
97
+ reason="Agent not registered in the Agent Registry (orphaned agents are refused)",
98
+ )
99
+ elif registration.status is not AgentStatus.ACTIVE:
100
+ decision = PolicyDecision(
101
+ decision=Decision.DENY,
102
+ reason=f"Agent is {registration.status.value} in the Agent Registry",
103
+ )
104
+ else:
105
+ try:
106
+ decision = await self._policy_engine.evaluate(call)
107
+ except PolicyEngineUnavailableError:
108
+ decision = PolicyDecision(decision=Decision.DENY, reason=_FAIL_CLOSED_REASON)
109
+
110
+ receipt = await self._ledger.record(agent, call, decision)
111
+ await self._audit_exporter.export(receipt)
112
+
113
+ if decision.decision is not Decision.ALLOW:
114
+ return _jsonrpc_error(
115
+ call.request_id,
116
+ code=-32000,
117
+ message=f"Policy violation: {decision.reason}",
118
+ instruction=_STOP_INSTRUCTION,
119
+ )
120
+
121
+ try:
122
+ credential = await self._broker_credential(agent, headers)
123
+ except TokenExchangeError as exc:
124
+ return _jsonrpc_error(
125
+ call.request_id,
126
+ code=-32004,
127
+ message=f"Credential brokering failed: {exc}",
128
+ instruction="Do not retry automatically; report the failure to the user.",
129
+ )
130
+
131
+ try:
132
+ return await self._mcp_upstream.forward(call, credential)
133
+ except UpstreamError as exc:
134
+ return _jsonrpc_error(
135
+ call.request_id,
136
+ code=-32002,
137
+ message=f"Upstream execution failed: {exc}",
138
+ instruction="Do not retry automatically; report the failure to the user.",
139
+ )
140
+
141
+ async def _broker_credential(self, agent: AgentIdentity, headers: dict[str, str]) -> str:
142
+ """Obtains the short-lived, audience-scoped credential for the upstream.
143
+
144
+ Delegated calls exchange the human sponsor's subject token. Autonomous
145
+ calls exchange the agent's own access token — RFC 8693 permits either,
146
+ and a real resource server validates the result's audience and scope, so
147
+ the agent must never be handed a credential it could reuse elsewhere.
148
+
149
+ The placeholder below is reachable only when no bearer token exists at
150
+ all, which means identity_mode="header" — the dev stub of ADR-0005,
151
+ where there is no real credential to broker in the first place.
152
+ """
153
+ normalized = {k.lower(): v for k, v in headers.items()}
154
+
155
+ if agent.human_sponsor:
156
+ subject_token = normalized.get(_SUBJECT_TOKEN_HEADER)
157
+ if not subject_token:
158
+ raise MissingSubjectTokenError(
159
+ f"delegated call for sponsor '{agent.human_sponsor}' "
160
+ f"requires a '{_SUBJECT_TOKEN_HEADER}' header"
161
+ )
162
+ return await self._token_exchange.exchange(subject_token, self._delegated_audience)
163
+
164
+ scheme, _, agent_token = normalized.get("authorization", "").partition(" ")
165
+ if scheme.lower() != "bearer" or not agent_token:
166
+ return f"autonomous::{agent.spiffe_id}"
167
+ return await self._token_exchange.exchange(agent_token, self._delegated_audience)
168
+
169
+
170
+ def _jsonrpc_error(
171
+ request_id: str | int | None, *, code: int, message: str, instruction: str
172
+ ) -> dict[str, Any]:
173
+ return {
174
+ "jsonrpc": "2.0",
175
+ "id": request_id,
176
+ "error": {"code": code, "message": message, "data": {"instruction": instruction}},
177
+ }