commit 944b3fde1992f8fa9246f2777ef6105c395cf32ed714f13fcd049cb319ec1c05 Author: Tyler King Date: Sat Apr 4 16:28:37 2026 -0400 feat: LLM Principal Broker MVP FastAPI companion service to the GSAP broker for AI agent identity delegation in governed shell sessions. Implements GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001: POST /delegate — request delegation (human → AI agent) POST /delegate/{id}/revoke — revoke delegation GET /delegate/{id} — delegation status GET /agents — list active delegations GET /health — health check Delegation lifecycle: REQUESTED → ACTIVE → EXPIRED | REVOKED Cascading revocation on delegator AC revocation Background cleanup of expired delegations (30s interval) Keycloak integration: Registers ephemeral agent clients per delegation Deletes clients on revocation/expiry Dev mode: stubs when no client_secret configured GSAP broker integration: Requests delegated ACs via on_behalf_of pattern Scope narrowing: agent ceiling ≤ delegator ceiling Dev mode: stubs when no bearer_token configured Chronicle integration: DELEGATION_CREATED (0x3001) DELEGATION_REVOKED (0x3003) DELEGATION_EXPIRED (0x3004) All 7 smoke tests pass (health, create, list, query, revoke, verify, empty). Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..764cb12 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# LLM Principal Broker — companion to fastapi-gsap + +# Service +LLM_BROKER_PORT=8092 + +# GSAP Broker +GSAP_BROKER_URL=http://localhost:8000 +GSAP_BEARER_TOKEN= + +# Keycloak Admin +KEYCLOAK_URL=http://localhost:8080 +KEYCLOAK_REALM=substrate +KEYCLOAK_ADMIN_CLIENT_ID=llm-broker-admin +KEYCLOAK_ADMIN_CLIENT_SECRET= + +# Chronicle (optional — events posted as Forgejo push webhooks) +CHRONICLE_WEBHOOK_URL= + +# Delegation Defaults +DEFAULT_DELEGATION_TTL_MINUTES=60 +DEFAULT_MAX_COMMANDS=500 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dfdfa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +*.db +.env diff --git a/llm_broker/__init__.py b/llm_broker/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/llm_broker/chronicle.py b/llm_broker/chronicle.py new file mode 100644 index 0000000..e978940 --- /dev/null +++ b/llm_broker/chronicle.py @@ -0,0 +1,65 @@ +"""Chronicle CloudEvents client — matches fastapi-gsap chronicle pattern.""" + +import hashlib +import json +import logging +from datetime import datetime, UTC + +import httpx + +from llm_broker.settings import settings + +logger = logging.getLogger(__name__) + + +async def emit(kind: str, payload: dict) -> str: + """Post a delegation event to Chronicle. Returns CID or empty string.""" + url = settings.chronicle_webhook_url + if not url: + return "" + try: + event_json = json.dumps({"kind": kind, **payload}, sort_keys=True, default=str) + cid = "sha256:" + hashlib.sha256(event_json.encode()).hexdigest() + async with httpx.AsyncClient(timeout=5.0) as client: + await client.post(url, json={ + "pusher": {"login": payload.get("delegator_did", settings.broker_did)}, + "ref": f"refs/llm-broker/{kind}", + "repository": {"full_name": "llm-broker/delegation"}, + "commits": [{"message": f"{kind}: {json.dumps(payload, default=str)}"}], + }, headers={"X-Forgejo-Event": "push"}) + return cid + except Exception as e: + logger.warning("Chronicle emit failed: %s: %s", kind, e) + return "" + + +async def delegation_created( + delegation_id: str, delegator_did: str, agent_did: str, + agent_type: str, scope: dict, +) -> str: + return await emit("DELEGATION_CREATED", { + "event_code": "0x3001", + "delegation_id": delegation_id, + "delegator_did": delegator_did, + "agent_did": agent_did, + "agent_type": agent_type, + "scope": scope, + "timestamp": datetime.now(UTC).isoformat(), + }) + + +async def delegation_revoked(delegation_id: str, reason: str) -> str: + return await emit("DELEGATION_REVOKED", { + "event_code": "0x3003", + "delegation_id": delegation_id, + "reason": reason, + "timestamp": datetime.now(UTC).isoformat(), + }) + + +async def delegation_expired(delegation_id: str) -> str: + return await emit("DELEGATION_EXPIRED", { + "event_code": "0x3004", + "delegation_id": delegation_id, + "timestamp": datetime.now(UTC).isoformat(), + }) diff --git a/llm_broker/db.py b/llm_broker/db.py new file mode 100644 index 0000000..124353b --- /dev/null +++ b/llm_broker/db.py @@ -0,0 +1,143 @@ +"""Database — SQLModel + aiosqlite, matching fastapi-gsap pattern.""" + +from datetime import datetime +from typing import Optional +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine +from sqlmodel import SQLModel, Field, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from llm_broker.settings import settings + +engine: AsyncEngine = create_async_engine(settings.database_url, echo=False) + + +class DelegationDB(SQLModel, table=True): + __tablename__ = "delegations" + + delegation_id: str = Field(primary_key=True) + status: str = Field(default="active", index=True) + agent_type: str + agent_model: Optional[str] = None + agent_did: str + agent_keycloak_client_id: Optional[str] = None + delegator_did: str + delegator_ac_id: str + delegated_ac_id: Optional[str] = None + capability_ceiling: str = "CAP_MUTATE" + ceremony_required_for: str = "" + max_commands: int = 500 + commands_executed: int = 0 + created_at: datetime = Field(default_factory=lambda: datetime.utcnow()) + expires_at: datetime = Field(default_factory=lambda: datetime.utcnow()) + revoked_at: Optional[datetime] = None + revoke_reason: Optional[str] = None + chronicle_cid: Optional[str] = None + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session(): + async with AsyncSession(engine) as session: + yield session + + +async def create_delegation(delegation: DelegationDB) -> None: + async with AsyncSession(engine) as session: + session.add(delegation) + await session.commit() + + +async def get_delegation(delegation_id: str) -> Optional[DelegationDB]: + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where(DelegationDB.delegation_id == delegation_id) + ) + return result.first() + + +async def revoke_delegation(delegation_id: str, reason: str) -> bool: + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where( + DelegationDB.delegation_id == delegation_id, + DelegationDB.status == "active", + ) + ) + d = result.first() + if not d: + return False + d.status = "revoked" + d.revoked_at = datetime.utcnow() + d.revoke_reason = reason + session.add(d) + await session.commit() + return True + + +async def get_active_delegations() -> list[DelegationDB]: + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where(DelegationDB.status == "active") + ) + return list(result.all()) + + +async def increment_commands(delegation_id: str) -> int: + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where(DelegationDB.delegation_id == delegation_id) + ) + d = result.first() + if not d: + return 0 + d.commands_executed += 1 + session.add(d) + await session.commit() + return d.commands_executed + + +async def expire_stale() -> list[DelegationDB]: + """Find and expire delegations past TTL or command limit.""" + now = datetime.utcnow() + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where(DelegationDB.status == "active") + ) + expired = [] + for d in result.all(): + if now > d.expires_at or d.commands_executed >= d.max_commands: + d.status = "expired" + d.revoke_reason = ( + "command_limit" if d.commands_executed >= d.max_commands else "ttl_elapsed" + ) + d.revoked_at = now + session.add(d) + expired.append(d) + await session.commit() + return expired + + +async def revoke_by_delegator_ac(delegator_ac_id: str) -> int: + """Cascading revocation — all delegations from a specific AC.""" + now = datetime.utcnow() + async with AsyncSession(engine) as session: + result = await session.exec( + select(DelegationDB).where( + DelegationDB.status == "active", + DelegationDB.delegator_ac_id == delegator_ac_id, + ) + ) + count = 0 + for d in result.all(): + d.status = "revoked" + d.revoked_at = now + d.revoke_reason = "delegator_ac_revoked" + session.add(d) + count += 1 + await session.commit() + return count diff --git a/llm_broker/delegation.py b/llm_broker/delegation.py new file mode 100644 index 0000000..bc30118 --- /dev/null +++ b/llm_broker/delegation.py @@ -0,0 +1,139 @@ +"""Core delegation lifecycle — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3.""" + +import logging +import uuid +from datetime import datetime, timedelta + +from llm_broker import chronicle, db +from llm_broker.db import DelegationDB +from llm_broker.gsap import GSAPClient +from llm_broker.keycloak import KeycloakAdmin +from llm_broker.models import ( + AgentPrincipal, + DelegationRequest, + DelegationResponse, + DelegationScope, +) +from llm_broker.settings import Settings + +logger = logging.getLogger(__name__) + + +class DelegationManager: + def __init__(self, config: Settings): + self.config = config + self.keycloak = KeycloakAdmin( + config.keycloak_url, + config.keycloak_realm, + config.keycloak_admin_client_id, + config.keycloak_admin_client_secret, + ) + self.gsap = GSAPClient(config.gsap_broker_url, config.gsap_bearer_token) + + async def create_delegation( + self, request: DelegationRequest, delegator_did: str + ) -> DelegationResponse: + delegation_id = f"del-{uuid.uuid4().hex[:8]}" + scope = request.scope or DelegationScope() + now = datetime.utcnow() + expires_at = now + timedelta(minutes=scope.max_ttl_minutes) + + agent_did = f"did:web:guildhouse.dev/agent/{request.agent_type}-{delegation_id}" + agent_client_id = f"agent-{request.agent_type}-{delegation_id}" + delegator_short = delegator_did.rsplit("/", 1)[-1] + agent_display = f"{request.agent_type} (delegated by {delegator_short})" + + # 1. Register agent in Keycloak + kc_result = await self.keycloak.register_agent_client( + client_id=agent_client_id, + display_name=agent_display, + delegator_did=delegator_did, + delegation_id=delegation_id, + agent_type=request.agent_type, + ) + + # 2. Request delegated AC from GSAP broker + try: + ac_result = await self.gsap.request_delegated_ac( + delegator_ac_id=request.delegator_ac_id, + agent_did=agent_did, + delegation_id=delegation_id, + corpus_entry_cid="sha256:dev-jumphost", + capability_ceiling=scope.capability_ceiling, + ttl_minutes=scope.max_ttl_minutes, + ) + except Exception as e: + await self.keycloak.delete_agent_client(agent_client_id) + raise RuntimeError(f"Failed to request delegated AC: {e}") + + # 3. Record in Chronicle + chronicle_cid = await chronicle.delegation_created( + delegation_id=delegation_id, + delegator_did=delegator_did, + agent_did=agent_did, + agent_type=request.agent_type, + scope=scope.model_dump(), + ) + + # 4. Persist + delegation = DelegationDB( + delegation_id=delegation_id, + status="active", + agent_type=request.agent_type, + agent_model=request.agent_model, + agent_did=agent_did, + agent_keycloak_client_id=agent_client_id, + delegator_did=delegator_did, + delegator_ac_id=request.delegator_ac_id, + delegated_ac_id=ac_result.get("context_id", ""), + capability_ceiling=scope.capability_ceiling, + ceremony_required_for=",".join(scope.ceremony_required_for), + max_commands=scope.max_commands, + created_at=now, + expires_at=expires_at, + chronicle_cid=chronicle_cid or None, + ) + await db.create_delegation(delegation) + + logger.info( + "Delegation created: %s (%s → %s)", delegation_id, delegator_did, agent_did + ) + + return DelegationResponse( + delegation_id=delegation_id, + agent_principal=AgentPrincipal( + did=agent_did, + keycloak_client_id=agent_client_id, + display_name=agent_display, + ), + delegated_ac=ac_result, + agent_token=kc_result.get("client_secret", ""), + expires_at=expires_at.isoformat(), + max_commands=scope.max_commands, + chronicle_cid=chronicle_cid, + ) + + async def revoke_delegation(self, delegation_id: str, reason: str) -> bool: + delegation = await db.get_delegation(delegation_id) + if not delegation or delegation.status != "active": + return False + + if delegation.agent_keycloak_client_id: + await self.keycloak.delete_agent_client(delegation.agent_keycloak_client_id) + + await db.revoke_delegation(delegation_id, reason) + await chronicle.delegation_revoked(delegation_id, reason) + + logger.info("Delegation revoked: %s (%s)", delegation_id, reason) + return True + + async def cleanup_expired(self) -> int: + """Expire stale delegations and clean up Keycloak clients.""" + expired = await db.expire_stale() + for d in expired: + if d.agent_keycloak_client_id: + await self.keycloak.delete_agent_client(d.agent_keycloak_client_id) + await chronicle.delegation_expired(d.delegation_id) + if expired: + logger.info("Expired %d stale delegations", len(expired)) + return len(expired) diff --git a/llm_broker/gsap.py b/llm_broker/gsap.py new file mode 100644 index 0000000..8241bef --- /dev/null +++ b/llm_broker/gsap.py @@ -0,0 +1,73 @@ +"""GSAP broker client — requests delegated ACs via on_behalf_of. + +Uses the same /governance/authorize/ endpoint as any other AC request, +with on_behalf_of set to the agent's DID. +""" + +import logging + +import httpx + +logger = logging.getLogger(__name__) + + +class GSAPClient: + def __init__(self, broker_url: str, bearer_token: str): + self.broker_url = broker_url.rstrip("/") + self.bearer_token = bearer_token + + async def request_delegated_ac( + self, + delegator_ac_id: str, + agent_did: str, + delegation_id: str, + corpus_entry_cid: str, + capability_ceiling: str, + ttl_minutes: int, + ) -> dict: + """Request an AC for the agent, delegated from the human's AC.""" + if not self.bearer_token: + logger.info("GSAP broker not configured — dev mode stub AC for %s", delegation_id) + return { + "status": "authorized", + "context_id": f"ac-dev-{delegation_id}", + "principal_did": agent_did, + "delegation_id": delegation_id, + "capability_ceiling": capability_ceiling, + } + + headers = {} + if self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + + request_body = { + "driver_id": "keycloak", + "principal": agent_did, + "playbook": f"delegation:{delegation_id}", + "corpus_entry_cid": corpus_entry_cid, + "parameters_cid": f"sha256:delegation-{delegation_id}", + "accord_template": "ai-delegation-standard", + "session_mode": "delegation", + "on_behalf_of": agent_did, + } + + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{self.broker_url}/governance/authorize/", + json=request_body, + headers=headers, + ) + resp.raise_for_status() + data = resp.json() + logger.info("Delegated AC issued: %s for %s", delegation_id, agent_did) + return data + + async def validate_ac(self, poll_token: str) -> dict | None: + """Validate that the delegator's AC is still active.""" + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get( + f"{self.broker_url}/governance/authorize/{poll_token}/", + ) + if resp.status_code == 200: + return resp.json() + return None diff --git a/llm_broker/keycloak.py b/llm_broker/keycloak.py new file mode 100644 index 0000000..b28e9fe --- /dev/null +++ b/llm_broker/keycloak.py @@ -0,0 +1,138 @@ +"""Keycloak Admin API client — ephemeral agent client registration. + +Registers and deletes confidential Keycloak clients for AI agent +delegations per GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1. +""" + +import logging +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class KeycloakAdmin: + def __init__(self, base_url: str, realm: str, client_id: str, client_secret: str): + self.base_url = base_url.rstrip("/") + self.realm = realm + self.client_id = client_id + self.client_secret = client_secret + self._token: Optional[str] = None + + async def _get_admin_token(self) -> str: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token", + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + resp.raise_for_status() + self._token = resp.json()["access_token"] + return self._token + + async def _headers(self) -> dict: + if not self._token: + await self._get_admin_token() + return {"Authorization": f"Bearer {self._token}"} + + async def register_agent_client( + self, + client_id: str, + display_name: str, + delegator_did: str, + delegation_id: str, + agent_type: str, + ) -> dict: + """Register ephemeral Keycloak client for an AI agent.""" + if not self.client_secret: + logger.info("Keycloak not configured — dev mode stub for %s", client_id) + return {"client_id": client_id, "client_secret": f"dev-secret-{delegation_id}", "client_uuid": None} + + headers = await self._headers() + + client_rep = { + "clientId": client_id, + "name": display_name, + "enabled": True, + "serviceAccountsEnabled": True, + "directAccessGrantsEnabled": False, + "publicClient": False, + "protocol": "openid-connect", + "attributes": { + "agent_type": agent_type, + "delegator_did": delegator_did, + "delegation_id": delegation_id, + }, + } + + async with httpx.AsyncClient(timeout=10.0) as http: + resp = await http.post( + f"{self.base_url}/admin/realms/{self.realm}/clients", + json=client_rep, + headers=headers, + ) + + if resp.status_code == 401: + headers = {"Authorization": f"Bearer {await self._get_admin_token()}"} + resp = await http.post( + f"{self.base_url}/admin/realms/{self.realm}/clients", + json=client_rep, + headers=headers, + ) + + resp.raise_for_status() + + # Retrieve the generated client secret + location = resp.headers.get("Location", "") + client_uuid = location.rstrip("/").split("/")[-1] if location else None + client_secret = "" + + if client_uuid: + secret_resp = await http.get( + f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}/client-secret", + headers=headers, + ) + if secret_resp.status_code == 200: + client_secret = secret_resp.json().get("value", "") + + logger.info("Registered agent client: %s (uuid=%s)", client_id, client_uuid) + return { + "client_id": client_id, + "client_secret": client_secret, + "client_uuid": client_uuid, + } + + async def delete_agent_client(self, client_id: str) -> bool: + """Delete ephemeral agent client on revocation/expiry.""" + if not self.client_secret: + logger.info("Keycloak not configured — dev mode stub delete for %s", client_id) + return True + + headers = await self._headers() + + async with httpx.AsyncClient(timeout=10.0) as http: + resp = await http.get( + f"{self.base_url}/admin/realms/{self.realm}/clients", + params={"clientId": client_id}, + headers=headers, + ) + if resp.status_code != 200: + return False + + clients = resp.json() + if not clients: + return False + + client_uuid = clients[0]["id"] + del_resp = await http.delete( + f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}", + headers=headers, + ) + deleted = del_resp.status_code in (200, 204) + if deleted: + logger.info("Deleted agent client: %s", client_id) + return deleted diff --git a/llm_broker/main.py b/llm_broker/main.py new file mode 100644 index 0000000..d5fa352 --- /dev/null +++ b/llm_broker/main.py @@ -0,0 +1,146 @@ +"""LLM Principal Broker — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001. + +AI agent identity delegation for governed shell sessions. +Companion service to the GSAP broker. +""" + +import asyncio +import logging +from contextlib import asynccontextmanager +from datetime import datetime + +import structlog +from fastapi import FastAPI, HTTPException, Header +from fastapi.middleware.cors import CORSMiddleware + +from llm_broker import db +from llm_broker.delegation import DelegationManager +from llm_broker.models import ( + ActiveDelegation, + AgentListResponse, + DelegationInfo, + DelegationRequest, + DelegationResponse, + DelegationStatus, + RevokeRequest, + RevokeResponse, +) +from llm_broker.settings import settings + +logger = structlog.get_logger() +manager = DelegationManager(settings) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await db.init_db() + logger.info("llm-principal-broker started", broker_did=settings.broker_did) + task = asyncio.create_task(_cleanup_loop()) + yield + task.cancel() + + +async def _cleanup_loop(): + """Periodically expire stale delegations (30s interval).""" + while True: + try: + count = await manager.cleanup_expired() + except Exception as e: + logger.warning("cleanup error", error=str(e)) + await asyncio.sleep(30) + + +app = FastAPI( + title="LLM Principal Broker", + description="AI agent identity delegation — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001", + version="0.1.0", + lifespan=lifespan, +) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.post("/delegate", response_model=DelegationResponse, tags=["Delegation"]) +async def create_delegation( + request: DelegationRequest, + x_delegator_did: str = Header(..., alias="X-Delegator-DID"), +): + """Request delegation of authority to an AI agent — §8.1.""" + try: + return await manager.create_delegation(request, x_delegator_did) + except RuntimeError as e: + raise HTTPException(status_code=502, detail=str(e)) + + +@app.post("/delegate/{delegation_id}/revoke", response_model=RevokeResponse, tags=["Delegation"]) +async def revoke_delegation( + delegation_id: str, + request: RevokeRequest = RevokeRequest(), +): + """Revoke an active delegation — §8.2.""" + success = await manager.revoke_delegation(delegation_id, request.reason) + if not success: + raise HTTPException(status_code=404, detail="Delegation not found or not active") + return RevokeResponse(delegation_id=delegation_id, reason=request.reason) + + +@app.get("/delegate/{delegation_id}", response_model=DelegationInfo, tags=["Delegation"]) +async def get_delegation(delegation_id: str): + """Query delegation status — §8.3.""" + d = await db.get_delegation(delegation_id) + if not d: + raise HTTPException(status_code=404, detail="Delegation not found") + + now = datetime.utcnow() + ttl_remaining = max(0, int((d.expires_at - now).total_seconds())) + + return DelegationInfo( + delegation_id=d.delegation_id, + status=DelegationStatus(d.status), + agent_did=d.agent_did, + agent_type=d.agent_type, + delegator_did=d.delegator_did, + commands_executed=d.commands_executed, + commands_remaining=max(0, d.max_commands - d.commands_executed), + ttl_remaining_seconds=ttl_remaining, + created_at=d.created_at.isoformat(), + expires_at=d.expires_at.isoformat(), + ) + + +@app.get("/agents", response_model=AgentListResponse, tags=["Delegation"]) +async def list_agents(): + """List all active agent delegations — §8.4.""" + active = await db.get_active_delegations() + now = datetime.utcnow() + + return AgentListResponse( + active_delegations=[ + ActiveDelegation( + delegation_id=d.delegation_id, + agent_type=d.agent_type, + delegator=d.delegator_did, + commands_executed=d.commands_executed, + ttl_remaining_seconds=max(0, int((d.expires_at - now).total_seconds())), + status=d.status, + ) + for d in active + ], + total_active=len(active), + ) + + +@app.get("/health", tags=["Health"]) +async def health(): + active = await db.get_active_delegations() + return { + "status": "healthy", + "service": "llm-principal-broker", + "version": "0.1.0", + "active_delegations": len(active), + } diff --git a/llm_broker/models.py b/llm_broker/models.py new file mode 100644 index 0000000..ae5ef6b --- /dev/null +++ b/llm_broker/models.py @@ -0,0 +1,100 @@ +"""Pydantic models — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3, §8.""" + +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime +from enum import Enum +from uuid import UUID + + +class DelegationStatus(str, Enum): + REQUESTED = "requested" + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + + +class DelegationScope(BaseModel): + inherit_corpus: bool = True + inherit_contexts: bool = True + capability_ceiling: str = "CAP_MUTATE" + ceremony_required_for: list[str] = Field( + default_factory=lambda: ["delete", "destroy", "drop"] + ) + prohibited_commands: list[str] = Field(default_factory=list) + max_ttl_minutes: int = 60 + max_commands: int = 500 + + +class DelegationRequest(BaseModel): + """POST /delegate request body — §8.1.""" + delegator_ac_id: str + agent_type: str = "claude-code" + agent_model: Optional[str] = None + scope: Optional[DelegationScope] = None + accord_template: str = "ai-delegation-standard" + + +class AgentPrincipal(BaseModel): + did: str + keycloak_client_id: str + display_name: str + + +class DelegatedAC(BaseModel): + context_id: str + delegator_did: str + agent_did: str + capability_ceiling: str + expires_at: str + delegation_chain: list[dict] + + +class DelegationResponse(BaseModel): + """POST /delegate response — §3.2.""" + delegation_id: str + agent_principal: AgentPrincipal + delegated_ac: dict + agent_token: str + expires_at: str + max_commands: int + chronicle_cid: Optional[str] = None + + +class DelegationInfo(BaseModel): + """GET /delegate/{id} response — §8.3.""" + delegation_id: str + status: DelegationStatus + agent_did: str + agent_type: str + delegator_did: str + commands_executed: int + commands_remaining: int + ttl_remaining_seconds: int + created_at: str + expires_at: str + + +class RevokeRequest(BaseModel): + reason: str = "manual_revocation" + + +class RevokeResponse(BaseModel): + delegation_id: str + status: str = "revoked" + reason: str + chronicle_cid: Optional[str] = None + + +class ActiveDelegation(BaseModel): + delegation_id: str + agent_type: str + delegator: str + commands_executed: int + ttl_remaining_seconds: int + status: str + + +class AgentListResponse(BaseModel): + active_delegations: list[ActiveDelegation] + total_active: int diff --git a/llm_broker/settings.py b/llm_broker/settings.py new file mode 100644 index 0000000..03c09a4 --- /dev/null +++ b/llm_broker/settings.py @@ -0,0 +1,39 @@ +"""Configuration — matches fastapi-gsap settings pattern.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Optional + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore") + + # Service + llm_broker_port: int = 8092 + broker_did: str = "did:web:guildhouse.dev/service/llm-broker" + + # GSAP broker + gsap_broker_url: str = "http://localhost:8000" + gsap_bearer_token: str = "" + + # Keycloak Admin + keycloak_url: str = "http://localhost:8080" + keycloak_realm: str = "substrate" + keycloak_admin_client_id: str = "llm-broker-admin" + keycloak_admin_client_secret: str = "" + + # Chronicle + chronicle_webhook_url: Optional[str] = None + + # Delegation defaults + default_delegation_ttl_minutes: int = 60 + default_max_commands: int = 500 + max_delegation_depth: int = 1 + + # CORS + cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"] + + # Database + database_url: str = "sqlite+aiosqlite:///./llm_broker.db" + + +settings = Settings() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1a9c1c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "llm-principal-broker" +version = "0.1.0" +description = "AI agent identity delegation for governed shell sessions — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.111.0", + "uvicorn[standard]>=0.29.0", + "pydantic>=2.7.0", + "pydantic-settings>=2.2.0", + "httpx>=0.27.0", + "sqlmodel>=0.0.19", + "aiosqlite>=0.20.0", + "structlog>=24.1.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.4"] + +[tool.hatch.build.targets.wheel] +packages = ["llm_broker"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..473a0f4