diff --git a/README.md b/README.md index 9b2b6bd..904bab7 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ pip install highflame # Python npm install @highflame/zeroid # Node / TypeScript ``` +Prefer a runnable walkthrough after installing the SDK? Open the [ZeroID Quickstart notebook](examples/zeroid_quickstart.ipynb) for an end-to-end demo covering agent registration, OAuth client credentials, agent-to-agent delegation, token introspection and revocation, credential policies, and CAE signals. + **Run ZeroID locally** (Docker — 30 seconds): ```bash diff --git a/examples/zeroid_quickstart.ipynb b/examples/zeroid_quickstart.ipynb new file mode 100644 index 0000000..15e62f3 --- /dev/null +++ b/examples/zeroid_quickstart.ipynb @@ -0,0 +1,1312 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-title", + "metadata": {}, + "source": [ + "# ZeroID Quickstart \u2014 Agent Identity Platform\n", + "\n", + "**End-to-end identity, authentication, and delegation for autonomous agents.**\n", + "\n", + "### The Problem\n", + "\n", + "As AI agents become autonomous \u2014 calling APIs, accessing databases, spawning sub-agents \u2014 a critical question emerges: **\"Who is this agent, what is it allowed to do, and who authorized it?\"**\n", + "\n", + "Traditional auth systems were built for humans clicking buttons. They don't answer:\n", + "- Which agent made this API call? (not just \"some service account\")\n", + "- Was it acting on its own, or delegated by another agent?\n", + "- What's its trust level? Is it a verified first-party agent or an unknown third-party?\n", + "- Can we revoke its access in real-time if it behaves anomalously?\n", + "\n", + "### How ZeroID Helps\n", + "\n", + "ZeroID gives every agent a **cryptographic identity** with:\n", + "- A stable **WIMSE/SPIFFE URI** as its globally unique identifier\n", + "- **Short-lived JWTs** issued via standard OAuth2 flows (no long-lived API keys)\n", + "- **Scoped delegation** \u2014 orchestrators can delegate authority to sub-agents with downscoped permissions\n", + "- **Trust levels** that advance through attestation (unverified \u2192 verified \u2192 first_party)\n", + "- **Real-time revocation** via Continuous Access Evaluation (CAE) signals\n", + "- **Full audit trail** \u2014 every token records who issued it, who delegated, and what scopes were granted\n", + "\n", + "### This Notebook\n", + "\n", + "We'll walk through the full lifecycle:\n", + "\n", + "1. **Setup & Connection** \u2014 install, connect, health check\n", + "2. **Register Agent Identities** \u2014 orchestrator + tool agent with ECDSA keys\n", + "3. **OAuth2 Client Credentials** \u2014 get a token, decode JWT claims\n", + "4. **Agent-to-Agent Delegation (RFC 8693)** \u2014 orchestrator delegates to tool agent\n", + "5. **Token Introspection & Revocation** \u2014 inspect and revoke tokens\n", + "6. **Credential Policies** \u2014 govern token issuance with policies\n", + "7. **CAE Signals** \u2014 real-time revocation on anomalous behavior\n", + "8. **Downstream Authorization** \u2014 how ZeroID tokens enable fine-grained access control\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "id": "cell-prereqs-md", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "### 1. Install the Highflame SDK\n", + "\n", + "```bash\n", + "pip install highflame cryptography PyJWT\n", + "```\n", + "\n", + "### 2. Start ZeroID locally\n", + "\n", + "Clone the ZeroID repo and start the service with Docker Compose:\n", + "\n", + "```bash\n", + "cd zeroid\n", + "make setup-keys # generate ECDSA P-256 signing keys\n", + "docker compose up -d # starts PostgreSQL + ZeroID on port 8899\n", + "```\n", + "\n", + "ZeroID exposes two logical surfaces on the same port:\n", + "- **Public endpoints** (`/oauth2/*`, `/health`, `/.well-known/*`) \u2014 no auth required\n", + "- **Admin endpoints** (`/api/v1/*`) \u2014 tenant context via `X-Account-ID` + `X-Project-ID` headers" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-prereqs-install", + "metadata": {}, + "outputs": [], + "source": [ + "#!pip install -q highflame cryptography PyJWT" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s1-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 1. Setup & Connection\n", + "\n", + "The `ZeroIDClient` wraps both the admin API (identity management, policies, signals)\n", + "and the public OAuth2 endpoints (token, introspect, revoke) in a single client." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s1-imports", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import time\n", + "import base64\n", + "from datetime import datetime, timezone\n", + "\n", + "from highflame.zeroid import ZeroIDClient\n", + "\n", + "\n", + "def pp(obj):\n", + " \"\"\"Pretty-print a dict or object as indented JSON.\"\"\"\n", + " if hasattr(obj, \"__dict__\"):\n", + " obj = obj.__dict__\n", + " print(json.dumps(obj, indent=2, default=str))\n", + "\n", + "\n", + "def decode_jwt_claims(token: str) -> dict:\n", + " \"\"\"Decode JWT payload without verification (for display only).\"\"\"\n", + " payload = token.split(\".\")[1]\n", + " # Add padding\n", + " payload += \"=\" * (4 - len(payload) % 4)\n", + " return json.loads(base64.urlsafe_b64decode(payload))\n", + "\n", + "\n", + "def _table(headers, rows):\n", + " \"\"\"Print a formatted table.\"\"\"\n", + " widths = [len(h) for h in headers]\n", + " str_rows = [[str(c) for c in row] for row in rows]\n", + " for row in str_rows:\n", + " for i, cell in enumerate(row):\n", + " widths[i] = max(widths[i], len(cell))\n", + " sep = \" \"\n", + " fmt = sep.join(f\"{{:<{w}}}\" for w in widths)\n", + " print(fmt.format(*headers))\n", + " print(sep.join(\"\\u2500\" * w for w in widths))\n", + " for row in str_rows:\n", + " print(fmt.format(*row))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s1-connect", + "metadata": {}, + "outputs": [], + "source": [ + "# Connect to local ZeroID instance.\n", + "# account_id and project_id are auto-generated if not provided \u2014\n", + "# every resource is scoped to this tenant.\n", + "client = ZeroIDClient(base_url=\"http://localhost:8899\")\n", + "\n", + "print(f\"ZeroID client configured:\")\n", + "print(f\" Base URL: {client._base_url}\")\n", + "print(f\" Account: {client.account_id}\")\n", + "print(f\" Project: {client.project_id}\")\n", + "print()\n", + "print(\"(Account and project auto-generated for this session. Pass them explicitly in production.)\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s1-health-md", + "metadata": {}, + "source": [ + "### Health Check\n", + "\n", + "Verify ZeroID is running and the database is reachable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s1-health", + "metadata": {}, + "outputs": [], + "source": [ + "health = client.health()\n", + "\n", + "print(f\"Status: {health.status}\")\n", + "print(f\"Service: {health.service}\")\n", + "print(f\"Uptime: {health.uptime_ms}ms\")\n", + "print(f\"Timestamp: {health.timestamp}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s1-discovery-md", + "metadata": {}, + "source": [ + "### OAuth2 Server Discovery\n", + "\n", + "ZeroID publishes its capabilities at `/.well-known/oauth-authorization-server` (RFC 8414)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s1-discovery", + "metadata": {}, + "outputs": [], + "source": [ + "# oauth_metadata() is not in the SDK \u2014 fetch the well-known endpoint directly.\n", + "import httpx\n", + "\n", + "metadata = httpx.get(f\"{client._base_url}/.well-known/oauth-authorization-server\").json()\n", + "\n", + "print(f\"Issuer: {metadata['issuer']}\")\n", + "print(f\"Token endpoint: {metadata['token_endpoint']}\")\n", + "print(f\"JWKS URI: {metadata['jwks_uri']}\")\n", + "print(f\"Grant types: {metadata['grant_types_supported']}\")\n", + "print(f\"Signing algs: {metadata['token_endpoint_auth_signing_alg_values_supported']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s2-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 2. Register Agent Identities\n", + "\n", + "### Why Agent Identity Matters\n", + "\n", + "Without identity, agents are invisible. You can't distinguish a billing agent from a data pipeline,\n", + "can't audit which agent accessed customer records, and can't revoke one agent without killing them all.\n", + "\n", + "ZeroID solves this by giving every agent a **registered identity** \u2014 just like humans have user accounts,\n", + "agents get identity records with classification, trust levels, and capabilities.\n", + "\n", + "### What Each Identity Gets\n", + "\n", + "- A stable **WIMSE URI** (SPIFFE-compatible): `spiffe://zeroid.dev/{account}/{project}/{type}/{external_id}`\n", + "- A **trust level**: `unverified` \u2192 `verified_third_party` \u2192 `first_party` (advances through attestation)\n", + "- A **type** + **sub-type** classification (e.g., `agent/orchestrator`, `agent/tool_agent`)\n", + "- Optional **ECDSA P-256 public key** for secretless authentication (jwt_bearer grant)\n", + "\n", + "### Identity Type Taxonomy\n", + "\n", + "| Identity Type | Sub-Types | When To Use |\n", + "|--------------|-----------|-------------|\n", + "| `agent` | `orchestrator`, `autonomous`, `tool_agent`, `human_proxy`, `evaluator` | AI agents that reason, plan, and act |\n", + "| `application` | `chatbot`, `assistant`, `api_service`, `code_agent`, `custom` | User-facing apps that call your APIs |\n", + "| `mcp_server` | (none) | MCP tool servers (Claude, Cursor, etc.) |\n", + "| `service` | (none) | Internal platform services (monitoring, ETL) |" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s2-orch-md", + "metadata": {}, + "source": [ + "### Register an Orchestrator Agent\n", + "\n", + "The orchestrator coordinates sub-agents and workflows. It authenticates via OAuth2 client credentials." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s2-orch", + "metadata": {}, + "outputs": [], + "source": [ + "from highflame.zeroid import ConflictError\n", + "\n", + "try:\n", + " orchestrator = client.identities.create(\n", + " external_id=\"billing-orchestrator\",\n", + " name=\"Billing Orchestrator Agent\",\n", + " identity_type=\"agent\",\n", + " sub_type=\"orchestrator\",\n", + " trust_level=\"first_party\",\n", + " owner_user_id=\"user-yash\",\n", + " allowed_scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n", + " framework=\"langchain\",\n", + " version=\"0.3.1\",\n", + " description=\"Orchestrates billing workflows across sub-agents\",\n", + " )\n", + " print(\"Identity registered (new)\")\n", + "except ConflictError:\n", + " # Already exists \u2014 fetch it by listing and filtering\n", + " identities = client.identities.list()\n", + " orchestrator = next(i for i in identities if i.external_id == \"billing-orchestrator\")\n", + " print(\"Identity already exists (reusing)\")\n", + "\n", + "print(f\" ID: {orchestrator.id}\")\n", + "print(f\" External ID: {orchestrator.external_id}\")\n", + "print(f\" Type: {orchestrator.identity_type}/{orchestrator.sub_type}\")\n", + "print(f\" Trust Level: {orchestrator.trust_level}\")\n", + "print(f\" Status: {orchestrator.status}\")\n", + "print(f\" WIMSE URI: {orchestrator.wimse_uri}\")\n", + "print(f\" Scopes: {orchestrator.allowed_scopes}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s2-wimse-md", + "metadata": {}, + "source": [ + "### WIMSE URI Format\n", + "\n", + "Every identity gets a stable, globally unique URI following the WIMSE/SPIFFE convention.\n", + "This URI becomes the `sub` claim in all issued JWTs.\n", + "\n", + "```\n", + "spiffe://zeroid.dev/{account_id}/{project_id}/{identity_type}/{external_id}\n", + "```\n", + "\n", + "Example: `spiffe://zeroid.dev/acct-demo-001/proj-demo-001/agent/billing-orchestrator`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s2-wimse", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "wimse_uri = orchestrator.wimse_uri\n", + "print(f\"WIMSE URI: {wimse_uri}\")\n", + "\n", + "# Parse the URI components\n", + "parts = wimse_uri.replace(\"spiffe://\", \"\").split(\"/\")\n", + "_table(\n", + " [\"Component\", \"Value\"],\n", + " [\n", + " [\"Domain\", parts[0]],\n", + " [\"Account ID\", parts[1]],\n", + " [\"Project ID\", parts[2]],\n", + " [\"Identity Type\", parts[3]],\n", + " [\"External ID\", parts[4]],\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s2-tool-md", + "metadata": {}, + "source": [ + "### Register a Tool Agent with ECDSA P-256 Public Key\n", + "\n", + "**When to use key-based identity (jwt_bearer):** When the agent manages its own keys and\n", + "you don't want shared secrets. The agent holds its private key securely and proves its\n", + "identity by signing a JWT assertion \u2014 no secret ever transmitted over the network.\n", + "\n", + "This is the preferred pattern for:\n", + "- **Third-party agents** you don't control (they bring their own keys)\n", + "- **High-security environments** where shared secrets are not acceptable\n", + "- **Agent-to-agent delegation** where the sub-agent needs to prove identity independently" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s2-tool-keygen", + "metadata": {}, + "outputs": [], + "source": [ + "from cryptography.hazmat.primitives.asymmetric import ec\n", + "from cryptography.hazmat.primitives import serialization\n", + "\n", + "# Generate the tool agent's ECDSA P-256 key pair\n", + "# In production, this key would be generated and stored securely by the agent.\n", + "tool_agent_private_key = ec.generate_private_key(ec.SECP256R1())\n", + "tool_agent_public_key = tool_agent_private_key.public_key()\n", + "\n", + "# Export public key as PEM for registration\n", + "public_key_pem = tool_agent_public_key.public_bytes(\n", + " encoding=serialization.Encoding.PEM,\n", + " format=serialization.PublicFormat.SubjectPublicKeyInfo,\n", + ").decode()\n", + "\n", + "print(\"Tool agent ECDSA P-256 public key (PEM):\")\n", + "print(public_key_pem)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s2-tool-register", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " tool_agent = client.identities.create(\n", + " external_id=\"data-fetcher\",\n", + " name=\"Data Fetcher Tool Agent\",\n", + " identity_type=\"agent\",\n", + " sub_type=\"tool_agent\",\n", + " trust_level=\"first_party\",\n", + " owner_user_id=\"user-yash\",\n", + " allowed_scopes=[\"data:read\"],\n", + " public_key_pem=public_key_pem,\n", + " framework=\"custom\",\n", + " version=\"1.0.0\",\n", + " description=\"Fetches data from internal databases\",\n", + " )\n", + " print(\"Identity registered (new)\")\n", + "except ConflictError:\n", + " # Already exists \u2014 fetch it by listing and filtering\n", + " identities = client.identities.list()\n", + " tool_agent = next(i for i in identities if i.external_id == \"data-fetcher\")\n", + " # Update with fresh public key (regenerated above)\n", + " tool_agent = client.identities.update(tool_agent.id, public_key_pem=public_key_pem)\n", + " print(\"Identity already exists (reusing, key updated)\")\n", + "\n", + "print(f\" ID: {tool_agent.id}\")\n", + "print(f\" External ID: {tool_agent.external_id}\")\n", + "print(f\" Type: {tool_agent.identity_type}/{tool_agent.sub_type}\")\n", + "print(f\" WIMSE URI: {tool_agent.wimse_uri}\")\n", + "print(f\" Has public key: {bool(tool_agent.public_key_pem)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s2-activate-md", + "metadata": {}, + "source": [ + "### Verify Identities\n", + "\n", + "Confirm both identities are registered and active." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s2-activate", + "metadata": {}, + "outputs": [], + "source": [ + "# Verify both identities\n", + "_table(\n", + " [\"Agent\", \"Status\", \"Trust Level\", \"WIMSE URI\"],\n", + " [\n", + " [orchestrator.name, orchestrator.status, orchestrator.trust_level, orchestrator.wimse_uri],\n", + " [tool_agent.name, tool_agent.status, tool_agent.trust_level, tool_agent.wimse_uri],\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s3-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 3. OAuth2 Client Credentials Flow\n", + "\n", + "### When To Use This\n", + "\n", + "This is the standard machine-to-machine flow \u2014 your platform provisions an OAuth client for an agent,\n", + "and the agent uses `client_id` + `client_secret` to get short-lived tokens. Use this when:\n", + "\n", + "- **Your platform manages the agent's secrets** (you control deployment)\n", + "- **The agent runs in your infrastructure** (not a third-party agent)\n", + "- **You want the simplest possible auth flow** (no crypto keys needed)\n", + "\n", + "The token expires in 1 hour by default \u2014 no long-lived API keys sitting in config files.\n", + "\n", + "```\n", + "Agent ZeroID\n", + " | |\n", + " |-- POST /oauth2/token ------->|\n", + " | grant_type: client_creds |\n", + " | client_id + client_secret |\n", + " | |\n", + " |<---- { access_token: JWT } --|\n", + " | (1hr TTL, ES256) |\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s3-client-md", + "metadata": {}, + "source": [ + "### Create OAuth Client for the Orchestrator" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s3-client", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " oauth_result = client.oauth_clients.create(\n", + " name=\"billing-orchestrator-client\",\n", + " external_id=\"billing-orchestrator\",\n", + " grant_types=[\"client_credentials\"],\n", + " scopes=[\"billing:read\", \"billing:write\", \"data:read\"],\n", + " )\n", + " oauth_client = oauth_result.client\n", + " client_secret = oauth_result.client_secret\n", + " print(\"OAuth client created (new)\")\n", + "except ConflictError:\n", + " # Already exists \u2014 find it and rotate the secret to get a fresh one\n", + " clients = client.oauth_clients.list()\n", + " existing = next(c for c in clients if c.client_id == \"billing-orchestrator\")\n", + " rotated = client.oauth_clients.rotate_secret(existing.id)\n", + " oauth_client = rotated.client\n", + " client_secret = rotated.client_secret\n", + " print(\"OAuth client already exists (secret rotated)\")\n", + "\n", + "print(f\" Client ID: {oauth_client.client_id}\")\n", + "print(f\" Client Secret: {client_secret[:20]}... (save this!)\")\n", + "print(f\" Grant Types: {oauth_client.grant_types}\")\n", + "print(f\" Scopes: {oauth_client.scopes}\")\n", + "print(f\" Identity ID: {oauth_client.identity_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s3-token-md", + "metadata": {}, + "source": [ + "### Get a Token via Client Credentials" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s3-token", + "metadata": {}, + "outputs": [], + "source": [ + "token_response = client.tokens.issue(\n", + " grant_type=\"client_credentials\",\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read data:read\",\n", + ")\n", + "\n", + "orchestrator_token = token_response.access_token\n", + "\n", + "print(f\"Token issued:\")\n", + "print(f\" Token Type: {token_response.token_type}\")\n", + "print(f\" Expires In: {token_response.expires_in}s\")\n", + "print(f\" Scope: {token_response.scope}\")\n", + "print(f\" JTI: {token_response.jti}\")\n", + "print(f\" Token: {orchestrator_token[:60]}...\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s3-decode-md", + "metadata": {}, + "source": [ + "### Decode JWT Claims\n", + "\n", + "ZeroID JWTs carry rich identity context. Downstream services can make authorization\n", + "decisions using these claims without calling back to ZeroID." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s3-decode", + "metadata": {}, + "outputs": [], + "source": [ + "claims = decode_jwt_claims(orchestrator_token)\n", + "\n", + "print(\"JWT Claims:\")\n", + "print(f\" iss (issuer): {claims.get('iss')}\")\n", + "print(f\" sub (subject): {claims.get('sub')}\")\n", + "print(f\" jti (token ID): {claims.get('jti')}\")\n", + "print(f\" account_id: {claims.get('account_id')}\")\n", + "print(f\" project_id: {claims.get('project_id')}\")\n", + "print(f\" identity_type: {claims.get('identity_type')}\")\n", + "print(f\" sub_type: {claims.get('sub_type')}\")\n", + "print(f\" trust_level: {claims.get('trust_level')}\")\n", + "print(f\" scopes: {claims.get('scopes')}\")\n", + "print(f\" grant_type: {claims.get('grant_type')}\")\n", + "print(f\" framework: {claims.get('framework')}\")\n", + "print(f\" delegation_depth: {claims.get('delegation_depth', 0)}\")\n", + "\n", + "# Timestamps\n", + "iat = datetime.fromtimestamp(claims['iat'], tz=timezone.utc)\n", + "exp = datetime.fromtimestamp(claims['exp'], tz=timezone.utc)\n", + "print(f\" iat (issued at): {iat.isoformat()}\")\n", + "print(f\" exp (expires at): {exp.isoformat()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s4-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 4. Agent-to-Agent Delegation (RFC 8693)\n", + "\n", + "### The Real-World Problem\n", + "\n", + "In multi-agent systems, an **orchestrator** coordinates specialized sub-agents. For example:\n", + "\n", + "> *\"Billing Orchestrator, process this invoice\"*\n", + "> \u2192 Orchestrator delegates to **Data Fetcher** (read customer data)\n", + "> \u2192 Orchestrator delegates to **Payment Processor** (charge the card)\n", + "> \u2192 Orchestrator delegates to **Notification Agent** (send receipt)\n", + "\n", + "Each sub-agent should only get the **minimum permissions it needs**, and the audit trail\n", + "should show the full chain: *who authorized what, and who delegated to whom.*\n", + "\n", + "Without delegation, you'd either:\n", + "- Give every sub-agent full access (dangerous)\n", + "- Hard-code service accounts per sub-agent (brittle, no audit trail)\n", + "- Build your own token-passing system (reinventing the wheel)\n", + "\n", + "### How ZeroID Handles This\n", + "\n", + "ZeroID implements **RFC 8693 Token Exchange**:\n", + "\n", + "1. The orchestrator holds an active token (`subject_token`)\n", + "2. The sub-agent proves its identity via a self-signed JWT assertion (`actor_token`)\n", + "3. ZeroID verifies both and issues a **delegated token** with:\n", + " - `sub` = sub-agent's WIMSE URI (who is acting)\n", + " - `act.sub` = orchestrator's WIMSE URI (who delegated)\n", + " - Scopes = intersection of orchestrator's scopes, sub-agent's allowed scopes, and requested scopes\n", + "\n", + "The sub-agent **cannot escalate** beyond what the orchestrator has. The delegation chain is\n", + "fully auditable. And revoking the orchestrator's token prevents future delegations.\n", + "\n", + "```\n", + "Orchestrator ZeroID Tool Agent\n", + " | | |\n", + " |-- subject_token ----->| |\n", + " | |<--- actor_token ----------|\n", + " | | (self-signed JWT) |\n", + " | | |\n", + " | |--- delegated_token ------>|\n", + " | | sub=tool_agent |\n", + " | | act.sub=orchestrator |\n", + " | | scopes=intersection |\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s4-assertion-md", + "metadata": {}, + "source": [ + "### Tool Agent Creates a Self-Signed JWT Assertion\n", + "\n", + "The tool agent signs a JWT with its private key. The `iss` claim is the agent's WIMSE URI,\n", + "and the `aud` claim targets the ZeroID issuer." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s4-assertion", + "metadata": {}, + "outputs": [], + "source": [ + "import jwt as pyjwt\n", + "\n", + "now = int(time.time())\n", + "issuer_url = metadata[\"issuer\"] # from the well-known discovery response (dict)\n", + "\n", + "# The tool agent builds its assertion JWT using its private key.\n", + "# This proves \"I am data-fetcher\" without any shared secret.\n", + "actor_assertion = pyjwt.encode(\n", + " {\n", + " \"iss\": tool_agent.wimse_uri, # who I am (WIMSE URI)\n", + " \"aud\": [issuer_url], # target: ZeroID\n", + " \"iat\": now,\n", + " \"exp\": now + 300, # 5 minute validity\n", + " },\n", + " tool_agent_private_key,\n", + " algorithm=\"ES256\",\n", + ")\n", + "\n", + "print(f\"Actor assertion created:\")\n", + "print(f\" Algorithm: ES256\")\n", + "print(f\" Issuer: {tool_agent.wimse_uri}\")\n", + "print(f\" Audience: {issuer_url}\")\n", + "print(f\" JWT: {actor_assertion[:60]}...\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s4-exchange-md", + "metadata": {}, + "source": [ + "### Token Exchange: Orchestrator Delegates to Tool Agent" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s4-exchange", + "metadata": {}, + "outputs": [], + "source": [ + "delegated_response = client.tokens.issue(\n", + " grant_type=\"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " subject_token=orchestrator_token, # orchestrator's active token\n", + " actor_token=actor_assertion, # tool agent's self-signed assertion\n", + " scope=\"data:read\", # requested scope (must be subset of both)\n", + ")\n", + "\n", + "delegated_token = delegated_response.access_token\n", + "\n", + "print(f\"Delegated token issued:\")\n", + "print(f\" Token Type: {delegated_response.token_type}\")\n", + "print(f\" Scope: {delegated_response.scope}\")\n", + "print(f\" Expires In: {delegated_response.expires_in}s\")\n", + "print(f\" Token: {delegated_token[:60]}...\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s4-claims-md", + "metadata": {}, + "source": [ + "### Inspect the Delegated Token Claims\n", + "\n", + "The delegated token carries an `act` (actor) claim showing the delegation chain.\n", + "Downstream services can see both **who is acting** and **who delegated**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s4-claims", + "metadata": {}, + "outputs": [], + "source": [ + "delegated_claims = decode_jwt_claims(delegated_token)\n", + "\n", + "print(\"Delegated token claims:\")\n", + "print(f\" sub (who is acting): {delegated_claims.get('sub')}\")\n", + "print(f\" identity_type: {delegated_claims.get('identity_type')}\")\n", + "print(f\" sub_type: {delegated_claims.get('sub_type')}\")\n", + "print(f\" scopes: {delegated_claims.get('scopes')}\")\n", + "print(f\" delegation_depth: {delegated_claims.get('delegation_depth')}\")\n", + "print(f\" grant_type: {delegated_claims.get('grant_type')}\")\n", + "print()\n", + "\n", + "# The act claim shows who delegated authority\n", + "act = delegated_claims.get(\"act\", {})\n", + "print(f\" act.sub (who delegated): {act.get('sub')}\")\n", + "print()\n", + "\n", + "# Summary\n", + "print(\"Delegation chain:\")\n", + "print(f\" {act.get('sub', 'unknown')}\")\n", + "print(f\" \u2514\u2500\u2500 delegated to \u2500\u2500> {delegated_claims.get('sub')}\")\n", + "print(f\" scopes: {delegated_claims.get('scopes')}, depth: {delegated_claims.get('delegation_depth')}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s5-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 5. Token Introspection & Revocation\n", + "\n", + "Tokens are bearer credentials \u2014 anyone who has one can use it. You need two capabilities:\n", + "\n", + "- **Introspect** \u2014 \"Is this token still valid?\" Downstream services call this before granting access.\n", + " Returns the full identity context without decoding the JWT themselves.\n", + "- **Revoke** \u2014 \"Kill this token now.\" When an agent is compromised, misbehaving, or decommissioned,\n", + " you revoke its credentials immediately \u2014 don't wait for expiry.\n", + "\n", + "ZeroID implements RFC 7662 (introspection) and RFC 7009 (revocation)." + ] + }, + { + "cell_type": "markdown", + "id": "cell-s5-introspect-md", + "metadata": {}, + "source": [ + "### Introspect an Active Token" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s5-introspect", + "metadata": {}, + "outputs": [], + "source": [ + "introspection = client.tokens.introspect(delegated_token)\n", + "\n", + "print(f\"Token introspection:\")\n", + "print(f\" active: {introspection.active}\")\n", + "print(f\" sub: {introspection.sub}\")\n", + "print(f\" iss: {introspection.iss}\")\n", + "print(f\" scope: {introspection.scope}\")\n", + "print(f\" trust_level: {introspection.trust_level}\")\n", + "print(f\" account_id: {introspection.account_id}\")\n", + "print(f\" project_id: {introspection.project_id}\")\n", + "\n", + "# TokenIntrospection has extra=\"allow\", so check for act via model_extra\n", + "act = getattr(introspection, \"act\", None) or introspection.model_extra.get(\"act\")\n", + "if act:\n", + " print(f\" act.sub: {act.get('sub')}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s5-revoke-md", + "metadata": {}, + "source": [ + "### Revoke the Token" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s5-revoke", + "metadata": {}, + "outputs": [], + "source": [ + "client.tokens.revoke(delegated_token)\n", + "print(\"Token revocation request sent (RFC 7009 \u2014 always returns 200).\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s5-verify-md", + "metadata": {}, + "source": [ + "### Verify Revocation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s5-verify", + "metadata": {}, + "outputs": [], + "source": [ + "introspection_after = client.tokens.introspect(delegated_token)\n", + "\n", + "print(f\"After revocation:\")\n", + "print(f\" active: {introspection_after.active}\")\n", + "\n", + "assert introspection_after.active is False, \"Token should be inactive after revocation\"\n", + "print(\"\\nToken successfully revoked.\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s6-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 6. Credential Policies\n", + "\n", + "Credential policies govern token issuance. They define constraints that ZeroID enforces\n", + "before signing any JWT:\n", + "\n", + "| Constraint | Description | Default |\n", + "|-----------|-------------|----------|\n", + "| `max_ttl_seconds` | Maximum token lifetime | 3600 (1 hour) |\n", + "| `max_delegation_depth` | Maximum delegation chain depth | 1 |\n", + "| `allowed_grant_types` | Which OAuth grants are permitted | `[api_key, client_credentials]` |\n", + "| `allowed_scopes` | Which scopes can be requested | (all) |\n", + "| `required_trust_level` | Minimum identity trust level | (none) |\n", + "| `required_attestation` | Minimum attestation level | (none) |" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s6-create-md", + "metadata": {}, + "source": [ + "### Create a Restrictive Policy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s6-create", + "metadata": {}, + "outputs": [], + "source": [ + "try:\n", + " policy = client.credential_policies.create(\n", + " name=\"short-lived-delegated\",\n", + " description=\"Short-lived tokens for delegated tool agents with depth cap\",\n", + " max_ttl_seconds=1800, # 30 minutes max\n", + " max_delegation_depth=2, # orchestrator -> tool -> (one more)\n", + " allowed_grant_types=[\n", + " \"client_credentials\",\n", + " \"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " ],\n", + " allowed_scopes=[\"data:read\", \"billing:read\"], # no write access\n", + " required_trust_level=\"verified_third_party\", # minimum trust\n", + " )\n", + " print(\"Credential policy created (new)\")\n", + "except ConflictError:\n", + " # Already exists \u2014 fetch it by listing and filtering\n", + " policies = client.credential_policies.list()\n", + " policy = next(p for p in policies if p.name == \"short-lived-delegated\")\n", + " print(\"Credential policy already exists (reusing)\")\n", + "\n", + "print(f\" ID: {policy.id}\")\n", + "print(f\" Name: {policy.name}\")\n", + "print(f\" Max TTL: {policy.max_ttl_seconds}s ({policy.max_ttl_seconds // 60} minutes)\")\n", + "print(f\" Max Delegation Depth: {policy.max_delegation_depth}\")\n", + "print(f\" Allowed Grant Types: {policy.allowed_grant_types}\")\n", + "print(f\" Allowed Scopes: {policy.allowed_scopes}\")\n", + "print(f\" Required Trust Level: {policy.required_trust_level}\")\n", + "print(f\" Active: {policy.is_active}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s6-list-md", + "metadata": {}, + "source": [ + "### List All Policies" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s6-list", + "metadata": {}, + "outputs": [], + "source": [ + "policies = client.credential_policies.list()\n", + "\n", + "print(f\"Total policies: {len(policies)}\\n\")\n", + "\n", + "rows = []\n", + "for p in policies:\n", + " rows.append([\n", + " p.name,\n", + " f\"{p.max_ttl_seconds}s\",\n", + " str(p.max_delegation_depth),\n", + " p.required_trust_level or \"-\",\n", + " str(p.is_active),\n", + " ])\n", + "\n", + "_table([\"Policy Name\", \"Max TTL\", \"Max Depth\", \"Trust Level\", \"Active\"], rows)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s7-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 7. CAE Signals (Real-time Revocation)\n", + "\n", + "### The Problem with Token Expiry Alone\n", + "\n", + "A 1-hour token means a compromised agent has up to 1 hour of unauthorized access.\n", + "Shorter TTLs help but add latency (more token refreshes). You need both:\n", + "short TTLs *and* the ability to kill tokens instantly when something goes wrong.\n", + "\n", + "### How CAE Signals Work\n", + "\n", + "**Continuous Access Evaluation (CAE)** lets your monitoring systems push risk signals\n", + "to ZeroID in real time. When a `high` or `critical` severity signal arrives,\n", + "ZeroID **automatically revokes all active credentials** for that identity \u2014 no human intervention.\n", + "\n", + "Use cases:\n", + "- Agent accessing 500+ records in 10 seconds \u2192 `anomalous_behavior` / `critical`\n", + "- Agent's IP changed unexpectedly \u2192 `ip_change` / `high`\n", + "- Agent owner left the company \u2192 `owner_change` / `critical`\n", + "- Routine policy update \u2192 `policy_violation` / `low` (logged, not revoked)\n", + "\n", + "| Signal Type | Description |\n", + "|------------|-------------|\n", + "| `credential_change` | Key material was rotated or compromised |\n", + "| `session_revoked` | Session was forcibly ended |\n", + "| `ip_change` | Unexpected IP address change |\n", + "| `anomalous_behavior` | Behavioral anomaly detected |\n", + "| `policy_violation` | Policy constraint was violated |\n", + "| `retirement` | Identity is being retired |\n", + "| `owner_change` | Identity ownership transferred |\n", + "\n", + "| Severity | Auto-Revoke? |\n", + "|---------|-------------|\n", + "| `low` | No \u2014 logged only |\n", + "| `medium` | No \u2014 logged only |\n", + "| `high` | **Yes** \u2014 all active credentials revoked |\n", + "| `critical` | **Yes** \u2014 all active credentials revoked |" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s7-setup-md", + "metadata": {}, + "source": [ + "### Issue a Fresh Token for the Orchestrator" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s7-setup", + "metadata": {}, + "outputs": [], + "source": [ + "# Get a fresh token for the orchestrator\n", + "fresh_token_response = client.tokens.issue(\n", + " grant_type=\"client_credentials\",\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read\",\n", + ")\n", + "fresh_token = fresh_token_response.access_token\n", + "\n", + "# Confirm it is active\n", + "result = client.tokens.introspect(fresh_token)\n", + "print(f\"Fresh token active: {result.active}\")\n", + "assert result.active is True" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s7-signal-md", + "metadata": {}, + "source": [ + "### Ingest a Critical CAE Signal\n", + "\n", + "Simulate an anomalous behavior detection. The critical severity will trigger\n", + "automatic revocation of all active credentials for this identity." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s7-signal", + "metadata": {}, + "outputs": [], + "source": [ + "signal = client.signals.ingest(\n", + " identity_id=orchestrator.id,\n", + " signal_type=\"anomalous_behavior\",\n", + " severity=\"critical\",\n", + " source=\"notebook-demo\",\n", + " payload={\n", + " \"reason\": \"Agent accessed 500+ records in 10 seconds\",\n", + " \"ip\": \"203.0.113.42\",\n", + " \"records_accessed\": 523,\n", + " },\n", + ")\n", + "\n", + "print(f\"CAE signal ingested:\")\n", + "print(f\" Signal ID: {signal.id}\")\n", + "print(f\" Type: {signal.signal_type}\")\n", + "print(f\" Severity: {signal.severity}\")\n", + "print(f\" Source: {signal.source}\")\n", + "print(f\" Identity ID: {signal.identity_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s7-verify-md", + "metadata": {}, + "source": [ + "### Verify Automatic Revocation" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s7-verify", + "metadata": {}, + "outputs": [], + "source": [ + "# Poll for revocation \u2014 CAE signal triggers async revocation.\n", + "timeout_seconds = 5\n", + "start_time = time.time()\n", + "while time.time() - start_time < timeout_seconds:\n", + " result_after_signal = client.tokens.introspect(fresh_token)\n", + " if not result_after_signal.active:\n", + " break\n", + " time.sleep(0.2)\n", + "\n", + "print(f\"Token status after CRITICAL CAE signal:\")\n", + "print(f\" active: {result_after_signal.active}\")\n", + "\n", + "assert result_after_signal.active is False, \"Token should be auto-revoked\"\n", + "print(f\"\\nCritical CAE signal automatically revoked all active credentials ({time.time() - start_time:.1f}s).\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s7-list-md", + "metadata": {}, + "source": [ + "### View Recent Signals" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s7-list", + "metadata": {}, + "outputs": [], + "source": [ + "recent_signals = client.signals.list(limit=5)\n", + "\n", + "print(f\"Recent signals ({len(recent_signals)}):\")\n", + "print()\n", + "\n", + "rows = []\n", + "for s in recent_signals:\n", + " rows.append([\n", + " s.signal_type,\n", + " s.severity,\n", + " s.source,\n", + " s.identity_id[:12] + \"...\",\n", + " str(s.created_at)[:19],\n", + " ])\n", + "\n", + "_table([\"Type\", \"Severity\", \"Source\", \"Identity\", \"Created\"], rows)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-s8-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## 8. ZeroID Tokens + Downstream Authorization\n", + "\n", + "ZeroID tokens are standard ES256 JWTs. Any service that fetches ZeroID's JWKS\n", + "(`/.well-known/jwks.json`) can verify them and use the embedded claims for authorization.\n", + "\n", + "The `trust_level`, `identity_type`, `sub_type`, and `scopes` claims make it possible to\n", + "write fine-grained authorization policies. For example, using Cedar:\n", + "\n", + "```cedar\n", + "// Allow first_party orchestrators to call any tool\n", + "permit (\n", + " principal is Agent,\n", + " action == Action::\"call_tool\",\n", + " resource\n", + ")\n", + "when {\n", + " context.trust_level == \"first_party\" &&\n", + " context.identity_type == \"agent\" &&\n", + " context.sub_type == \"orchestrator\"\n", + "};\n", + "\n", + "// Deny unverified agents from sensitive operations\n", + "forbid (\n", + " principal is Agent,\n", + " action == Action::\"call_tool\",\n", + " resource\n", + ")\n", + "when {\n", + " context.trust_level == \"unverified\" &&\n", + " resource.is_sensitive == true\n", + "};\n", + "```\n", + "\n", + "Downstream services verify the JWT signature against ZeroID's JWKS, then map the claims\n", + "into their authorization context \u2014 no callback to ZeroID needed.\n", + "\n", + "### Highflame Platform Integration\n", + "\n", + "For teams that don't want to build their own policy engine, the\n", + "[Highflame platform](https://highflame.com) consumes ZeroID tokens natively and provides:\n", + "\n", + "- **Cedar policy engine** \u2014 write and enforce fine-grained authorization policies using the ZeroID JWT claims above\n", + "- **Guardrails** \u2014 content safety, prompt injection detection, and tool call governance, all identity-aware via ZeroID\n", + "- **Observability** \u2014 trace every agent action back to its ZeroID identity, delegation chain, and trust level\n", + "\n", + "ZeroID handles *who the agent is*. Highflame handles *what the agent can do*." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-s8-example", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "# Get a fresh orchestrator token (previous was revoked by CAE signal)\n", + "fresh_orch_token = client.tokens.issue(\n", + " grant_type=\"client_credentials\",\n", + " client_id=oauth_client.client_id,\n", + " client_secret=client_secret,\n", + " scope=\"billing:read\",\n", + ").access_token\n", + "\n", + "print(\"ZeroID JWTs carry rich identity context for downstream authorization.\")\n", + "print()\n", + "print(\"Any service that trusts ZeroID's JWKS can verify the token and use these claims:\")\n", + "print()\n", + "\n", + "claims = decode_jwt_claims(fresh_orch_token)\n", + "_table(\n", + " [\"JWT Claim\", \"Value\", \"Authorization Use\"],\n", + " [\n", + " [\"sub\", claims.get(\"sub\", \"\")[:60] + \"...\", \"Who is this agent?\"],\n", + " [\"trust_level\", claims.get(\"trust_level\", \"\"), \"How much do we trust it?\"],\n", + " [\"identity_type\", claims.get(\"identity_type\", \"\"), \"What kind of principal?\"],\n", + " [\"sub_type\", claims.get(\"sub_type\", \"\"), \"What role does it play?\"],\n", + " [\"scopes\", str(claims.get(\"scopes\", [])), \"What is it allowed to do?\"],\n", + " [\"delegation_depth\", str(claims.get(\"delegation_depth\", 0)), \"Is it acting on its own or delegated?\"],\n", + " [\"framework\", claims.get(\"framework\", \"\"), \"Which agent framework?\"],\n", + " ],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-summary-md", + "metadata": {}, + "source": [ + "---\n", + "\n", + "## Quick Reference\n", + "\n", + "### SDK Client Methods\n", + "\n", + "| Method | Description |\n", + "|--------|-------------|\n", + "| `client.health()` | Health check |\n", + "| `client.jwks()` | Fetch JWKS (public keys) |\n", + "| `client.identities.create(...)` | Register an identity |\n", + "| `client.identities.get(id)` | Get identity by UUID |\n", + "| `client.identities.list()` | List identities |\n", + "| `client.identities.update(id, ...)` | Update identity fields |\n", + "| `client.identities.delete(id)` | Deactivate identity (soft delete) |\n", + "| `client.oauth_clients.create(...)` | Register an OAuth2 client |\n", + "| `client.oauth_clients.list()` | List OAuth2 clients |\n", + "| `client.tokens.issue(...)` | Issue a token (all grant types) |\n", + "| `client.tokens.introspect(token)` | Introspect a token (RFC 7662) |\n", + "| `client.tokens.revoke(token)` | Revoke a token (RFC 7009) |\n", + "| `client.credential_policies.create(...)` | Create a credential policy |\n", + "| `client.credential_policies.list()` | List policies |\n", + "| `client.signals.ingest(...)` | Ingest a CAE signal |\n", + "| `client.signals.list(limit=N)` | List recent signals |\n", + "\n", + "### OAuth2 Grant Types\n", + "\n", + "| Grant Type | Use Case | Auth Material |\n", + "|-----------|----------|---------------|\n", + "| `client_credentials` | Machine-to-machine (RFC 6749 \u00a74.4) | `client_id` + `client_secret` |\n", + "| `urn:ietf:params:oauth:grant-type:jwt-bearer` | Key-based (RFC 7523) | Self-signed JWT assertion |\n", + "| `urn:ietf:params:oauth:grant-type:token-exchange` | Delegation (RFC 8693) | `subject_token` + `actor_token` |\n", + "| `api_key` | SDK/CLI authentication | `zid_sk_*` API key |\n", + "| `authorization_code` | PKCE flow (CLI, MCP clients) | Auth code + code verifier |\n", + "| `refresh_token` | Token rotation | Refresh token |\n", + "\n", + "### JWT Claims\n", + "\n", + "| Claim | Description |\n", + "|-------|-------------|\n", + "| `iss` | Issuer (ZeroID server URL) |\n", + "| `sub` | Subject (WIMSE URI of the identity) |\n", + "| `jti` | Unique token ID |\n", + "| `iat` / `exp` | Issued at / expires at |\n", + "| `account_id` / `project_id` | Tenant scope |\n", + "| `identity_type` / `sub_type` | Identity classification |\n", + "| `trust_level` | Trust level at issuance time |\n", + "| `scopes` | Granted scopes |\n", + "| `grant_type` | How the token was issued |\n", + "| `delegation_depth` | Depth in the delegation chain (0 = direct) |\n", + "| `act.sub` | Who delegated (for token_exchange tokens) |\n", + "| `framework` / `version` / `publisher` | Agent metadata |\n", + "| `capabilities` | Agent capabilities (JSON) |\n", + "\n", + "---\n", + "\n", + "Built with [ZeroID](https://zeroid.dev) by [Highflame](https://highflame.com) \u2014 the control plane for autonomous AI." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}