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
|
@@ -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
|
+
}
|