From 0b5c6a9391fc1b67001e2ade4d80cb561e708ab5 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 3 Aug 2026 15:47:39 -0700 Subject: [PATCH] feat(keycardai-langchain): add LangChain integration package Delegated access at the tool-call boundary for LangChain 1.x agents: KeycardGrantMiddleware implements wrap_tool_call, acquires resource tokens under the identity of the run via keycardai-oauth, and exposes the result to tools as a non-throwing AccessContext. KeycardIdentity selects the access pattern per invocation: - subject_token: on-behalf-of (RFC 8693 token exchange) - as_self=True: the agent acts as itself (client credentials); never interrupts, since no user exists to send to a consent page - user_identifier: impersonation (forbidden by default, zone policy) Missing grants and sign-in pause the run with LangGraph interrupts when authorization_url / sign_in_url are set. keycardai.langchain.testing provides mock_access_context for testing tools without a zone. Includes two runnable examples (user-facing on-behalf-of agent with the interrupt/resume loop; background as-itself agent), extracted from the demo spike verified against a live zone. Registers the package in the justfile test and test-coverage targets so its suite runs in CI (fastmcp is missing from those lists, ECO-172). --- justfile | 2 + packages/langchain/README.md | 263 +++++++++++++ .../examples/background_agent/README.md | 39 ++ .../examples/background_agent/main.py | 127 +++++++ .../examples/background_agent/pyproject.toml | 14 + .../examples/user_facing_agent/README.md | 45 +++ .../examples/user_facing_agent/main.py | 122 ++++++ .../examples/user_facing_agent/pyproject.toml | 14 + packages/langchain/pyproject.toml | 124 +++++++ .../src/keycardai/langchain/__init__.py | 67 ++++ .../src/keycardai/langchain/middleware.py | 351 ++++++++++++++++++ .../keycardai/langchain/testing/__init__.py | 13 + .../keycardai/langchain/testing/test_utils.py | 84 +++++ packages/langchain/tests/test_middleware.py | 266 +++++++++++++ packages/langchain/tests/test_testing_seam.py | 49 +++ pyproject.toml | 1 + uv.lock | 28 ++ 17 files changed, 1609 insertions(+) create mode 100644 packages/langchain/README.md create mode 100644 packages/langchain/examples/background_agent/README.md create mode 100644 packages/langchain/examples/background_agent/main.py create mode 100644 packages/langchain/examples/background_agent/pyproject.toml create mode 100644 packages/langchain/examples/user_facing_agent/README.md create mode 100644 packages/langchain/examples/user_facing_agent/main.py create mode 100644 packages/langchain/examples/user_facing_agent/pyproject.toml create mode 100644 packages/langchain/pyproject.toml create mode 100644 packages/langchain/src/keycardai/langchain/__init__.py create mode 100644 packages/langchain/src/keycardai/langchain/middleware.py create mode 100644 packages/langchain/src/keycardai/langchain/testing/__init__.py create mode 100644 packages/langchain/src/keycardai/langchain/testing/test_utils.py create mode 100644 packages/langchain/tests/test_middleware.py create mode 100644 packages/langchain/tests/test_testing_seam.py diff --git a/justfile b/justfile index c79cc53..429ba98 100644 --- a/justfile +++ b/justfile @@ -18,6 +18,7 @@ test: build just test-package fastmcp just test-package mcp-fastmcp just test-package a2a + just test-package langchain # Run tests for a specific package test-package PACKAGE: @@ -41,6 +42,7 @@ test-coverage: build cd packages/fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=60 cd packages/mcp-fastmcp && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=70 cd packages/a2a && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=55 + cd packages/langchain && uv run --extra test pytest tests/ -v --cov=src --cov-report=term-missing --cov-fail-under=85 check: uv run ruff check diff --git a/packages/langchain/README.md b/packages/langchain/README.md new file mode 100644 index 0000000..3ee1f0f --- /dev/null +++ b/packages/langchain/README.md @@ -0,0 +1,263 @@ +# keycardai-langchain + +Keycard integration for LangChain agents. Every tool call gets a short-lived +credential brokered by Keycard, scoped to the identity the agent is acting for, +and recorded in the audit log. + +Your tools never hold an API key, the model never sees a credential, and you do +not write an OAuth flow. + +## Install + +```bash +pip install keycardai-langchain +``` + +## Quick start + +```python +from langchain.agents import create_agent +from langchain.tools import tool + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = "https://www.googleapis.com/calendar/v3" + +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=[CALENDAR], + client_id="your-agent", + client_secret=..., +) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events.""" + token = get_access_context().access(CALENDAR).access_token + ... + + +agent = create_agent( + model, + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +That is the whole integration: one middleware in the agent's middleware list, +and one call inside each tool to read the credential for this call. + +## How it works + +`KeycardGrantMiddleware` implements LangChain's `wrap_tool_call` hook, so it +runs at the tool-call boundary. Before each tool executes it acquires tokens +for the declared resources under the identity of the run, then exposes the +result to the tool as an `AccessContext`. + +The middleware is framework-shaped rather than Keycard-shaped: identity travels +on the agent's own `context_schema`, and the pause-for-authorization flow is a +LangGraph interrupt. Nothing is bolted onto the side of the framework. + +The same middleware instance works under `create_agent`, a raw LangGraph graph, +and `create_deep_agent` (deep agents are built on the same middleware system). + +## Access patterns + +`KeycardIdentity` carries the identity for a run, and its fields select the +access pattern: + +| Field | Pattern | Meaning | +|---|---|---| +| `subject_token` | on-behalf-of | Exchange the caller's own token for resource tokens (RFC 8693). | +| `as_self=True` | as itself | Client-credentials grant under the agent's own application identity. No user anywhere. | +| `user_identifier` | impersonation | Substitute-user exchange, authenticated by the agent's credential. Forbidden by default; requires a zone policy. | + +A run with no identity at all is an error (or a sign-in interrupt), never a +silent fallback to the agent's own authority — acting as itself is always an +explicit choice. + +### On-behalf-of: a user-facing agent + +The agent acts for the person in the chat. Their token is exchanged per tool +call, so every resource access is attributed to agent-for-user in the audit +log, and revoking the user's grant cuts the agent off immediately. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # Optional: pause the run in-chat instead of failing. + sign_in_url="https://your-app.example/signin", + authorization_url="https://your-app.example/authorize", +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), +) +``` + +Runnable version: [`examples/user_facing_agent`](examples/user_facing_agent). + +### As itself: a background agent + +No user in the loop — a scheduled digest, a queue worker, a monitor. The agent +authenticates as its own application and Keycard delivers whatever credential +the zone brokers for the resource, including vaulted secrets, so the worker's +environment holds no API keys and revocation lives in one place. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.github.com"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(as_self=True), +) +``` + +As-itself runs never pause on an interrupt, even when `sign_in_url` or +`authorization_url` is set: there is no user to send to a consent page, so a +denied grant stays on the `AccessContext` as an error for the tool and the +operator's logs. + +Runnable version: [`examples/background_agent`](examples/background_agent). + +### Impersonation: acting as a specific user without their token + +The agent asks for tokens *as* a named user, authenticated only by its own +credential. This is the sharpest tool in the box and is forbidden by default; +it requires an explicit impersonation policy in the zone. + +```python +keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://www.googleapis.com/calendar/v3"], + client_id="your-agent", + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], +) + +agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(user_identifier="user@example.com"), +) +``` + +### Identity without per-run context + +For a deployed agent whose surface does not thread per-run context, set +`fallback_identity`. Pass a **callable** to resolve it per tool call, so a +sign-in that happens mid-conversation takes effect on resume without a restart: + +```python +keycard = KeycardGrantMiddleware( + ..., + fallback_identity=lambda: KeycardIdentity(subject_token=session_token()), +) +``` + +## Errors are data, not exceptions + +A missing grant is normal operation in a brokered setup, so the `AccessContext` +records failures instead of raising. Only `access(resource)` raises, and only +when you ask for a resource that has no token: + +```python +access = get_access_context() +if access.has_errors(): + return f"Cannot reach the API yet: {access.get_errors()}" +token = access.access(CALENDAR).access_token +``` + +Returning a readable sentence beats raising here: in a chat UI a raised +exception reads as an internal error, when the truthful message is "you have +not granted this yet." + +## Pausing for sign-in and consent + +With `sign_in_url` and `authorization_url` set, the middleware pauses the run +with a LangGraph interrupt instead of failing, so the whole flow can live in +your chat surface: + +```python +keycard = KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], + sign_in_url="https://your-app.example/signin", + authorization_url=lambda resources: f"https://your-app.example/authorize?r={resources[0]}", +) +``` + +| Payload `type` | Fires when | Resume behavior | +|---|---|---| +| `sign_in_required` | The run carries no identity | Identity is re-resolved, then the exchange runs | +| `authorization_required` | Identity present, grant missing | The exchange is retried | + +Both require a checkpointer. Two details worth knowing: + +- **Resume needs no new token.** Consent changes the grant in the zone, not the + token in your session, so the existing subject token exchanges successfully + afterward. +- **Runtime context is not checkpointed.** A resume must re-supply identity, + which a server does on every run anyway. + +Scope granularity falls out of this for free: if a user has granted read but +not write, the read call succeeds and the write call is the one that pauses. + +## Per-tool resources and scopes + +```python +KeycardGrantMiddleware( + zone_url=..., + resources=[CALENDAR], # default for every tool + tool_resources={"post_message": [SLACK]}, # per-tool override + request_scopes={CALENDAR: ["calendar.events"]}, +) +``` + +`request_scopes` is the **outbound** scope requested from Keycard, for both the +exchange and the as-itself grant. It is distinct from any scope enforced on the +caller's inbound token. + +## Testing + +```python +from keycardai.langchain.testing import mock_access_context + + +def test_list_events(): + with mock_access_context(resource_tokens={CALENDAR: "test-token"}): + assert list_events.invoke({"days_ahead": 0}) +``` + +`mock_access_context(access_token=...)` serves one token for any resource, which +is convenient but cannot catch a mistyped resource URL, since every lookup +succeeds. Pass `resource_tokens={...}` when the test should assert which +resource a tool reads. `resource_errors=` and `error_message=` cover the failure +paths, and `override_access_context` takes a hand-built context for full +control. + +## A note on tool arguments + +Give tools arguments that express **intent**, and keep configuration and clocks +out of the model's hands. A tool that accepts a resource URL will eventually be +called with a resource the model invented; a tool that accepts an absolute +timestamp will eventually be called with the wrong date. Prefer +`days_ahead: int` over an ISO string, and read the resource from configuration. diff --git a/packages/langchain/examples/background_agent/README.md b/packages/langchain/examples/background_agent/README.md new file mode 100644 index 0000000..f3463eb --- /dev/null +++ b/packages/langchain/examples/background_agent/README.md @@ -0,0 +1,39 @@ +# Background Agent (as itself) + +A LangChain agent with no user anywhere: a scheduled PR-review digest that +fetches open pull requests from GitHub and summarizes them. It authenticates +as its own Keycard application (`KeycardIdentity(as_self=True)`), and Keycard +delivers whatever credential the zone brokers for the GitHub resource — a +vaulted PAT, a GitHub App token — per tool call. The worker's environment +holds no GitHub credential, and revoking access happens in one place. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://api.github.com` and attach a credential + provider that can serve it (for example a zone vault holding a GitHub + token, or a GitHub App provider). +4. Grant the application access to the resource. + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export ANTHROPIC_API_KEY="sk-ant-..." +export DIGEST_REPOS="your-org/repo-a,your-org/repo-b" # optional + +uv run main.py +``` + +## What to look at + +- The middleware has **no** `sign_in_url` / `authorization_url`: there is no + user in this process, so a denied grant is an error on the `AccessContext`, + never a consent pause. +- The tool reads its token with `get_access_context().access(...)`; no + credential appears in the environment, the code, or the model's context. +- Each run of the digest produces audit events in the zone with the + application as the actor. diff --git a/packages/langchain/examples/background_agent/main.py b/packages/langchain/examples/background_agent/main.py new file mode 100644 index 0000000..a76bcd4 --- /dev/null +++ b/packages/langchain/examples/background_agent/main.py @@ -0,0 +1,127 @@ +"""A background agent with no user anywhere: a morning PR-review digest. + +The agent runs as itself (KeycardIdentity(as_self=True)): resource access is +attributed to the application alone, the GitHub credential lives in the zone +(vaulted or brokered), and every fetch is an audit event. Nothing in this +process or its environment holds a GitHub credential. + +Run: uv run main.py +(In real use this is a cron entry; running it by hand is the same thing.) +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +GITHUB = os.environ.get("KEYCARD_GITHUB_RESOURCE", "https://api.github.com") +REPOS = [ + r.strip() + for r in os.environ.get("DIGEST_REPOS", "langchain-ai/langchain").split(",") + if r.strip() +] + +# One client for the process: connection reuse across tool calls, bounded waits. +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_open_pull_requests() -> str: + """List open pull requests across the configured repositories.""" + access = get_access_context() + if access.has_error(): + return f"Cannot reach GitHub: {access.get_error()['message']}" + if access.has_resource_error(GITHUB): + return f"GitHub access not granted: {access.get_resource_error(GITHUB)}" + + headers = { + "Authorization": f"Bearer {access.access(GITHUB).access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + now = datetime.now(timezone.utc) + report: dict[str, list[dict] | str] = {} + for repo in REPOS: + response = _http.get( + f"{GITHUB}/repos/{repo}/pulls", + params={"state": "open", "per_page": 20}, + headers=headers, + ) + if response.status_code != 200: + report[repo] = f"error {response.status_code}: {response.text[:200]}" + continue + report[repo] = [ + { + "number": pr["number"], + "title": pr["title"], + "author": pr["user"]["login"], + "draft": pr["draft"], + "age_days": (now - datetime.fromisoformat(pr["created_at"])).days, + } + for pr in response.json() + ] + return json.dumps(report, indent=2) + + +def _text_of(message) -> str: + """Final text from a message, whether content is a string or block list.""" + content = message.content + if isinstance(content, str): + return content + return "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + + +SYSTEM_PROMPT = ( + "You compile a morning review digest for a maintainer. Fetch the open " + "pull requests, then write a short digest: what needs review first, " + "what is a draft and can wait, and anything that looks stuck. " + "Plain prose, under 200 words." +) + + +def main() -> None: + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[GITHUB], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # No authorization_url / sign_in_url on purpose: there is no user in + # this process, so access failures are errors, never consent pauses. + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_open_pull_requests], + system_prompt=SYSTEM_PROMPT, + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + result = agent.invoke( + {"messages": [HumanMessage("Compile this morning's review digest.")]}, + context=KeycardIdentity(as_self=True), + ) + print(_text_of(result["messages"][-1])) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/background_agent/pyproject.toml b/packages/langchain/examples/background_agent/pyproject.toml new file mode 100644 index 0000000..b420575 --- /dev/null +++ b/packages/langchain/examples/background_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "background-agent-example" +version = "0.1.0" +description = "A background LangChain agent acting as itself, with credentials brokered by Keycard" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/examples/user_facing_agent/README.md b/packages/langchain/examples/user_facing_agent/README.md new file mode 100644 index 0000000..388014a --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/README.md @@ -0,0 +1,45 @@ +# User-Facing Agent (on behalf of) + +A LangChain calendar assistant that acts **on behalf of** the person invoking +it. The caller's Keycard token is exchanged per tool call for a Google +Calendar token (RFC 8693 token exchange), so access is attributed to +agent-for-user in the audit log and revoking the user's grant cuts the agent +off immediately. + +When the user has not granted calendar access yet, the middleware pauses the +run with a LangGraph `authorization_required` interrupt. This CLI prints the +consent link, waits, and resumes the same run — in a chat UI the same payload +becomes an in-chat sign-in card. + +## Keycard setup + +1. Create a **zone** at [keycard.ai](https://keycard.ai) (or use an existing one). +2. Create an **application** for the agent and note its client ID and secret. +3. Create a **resource** for `https://www.googleapis.com/calendar/v3` with a + Google OAuth credential provider. +4. Sign in through the agent's application to obtain a subject token for the + user (any OAuth client can drive this; the token must be issued by your + zone with the agent's application as the audience owner). + +## Run + +```bash +export KEYCARD_ZONE_URL="https://your-zone.keycard.cloud" +export KEYCARD_CLIENT_ID="your-agent-client-id" +export KEYCARD_CLIENT_SECRET="your-agent-client-secret" +export KEYCARD_SUBJECT_TOKEN="" +export ANTHROPIC_API_KEY="sk-ant-..." + +uv run main.py "what's on my calendar today?" +``` + +## What to look at + +- The identity for the run is `KeycardIdentity(subject_token=...)`, passed as + LangChain runtime context — not middleware state, so one deployed agent + serves many users. +- The interrupt/resume loop at the bottom of `main.py`: consent changes the + grant in the zone, not the token in your session, so the resume retries the + exchange with the same subject token and succeeds. +- The tool never sees a Google credential until the moment of the call, and + the model never sees one at all. diff --git a/packages/langchain/examples/user_facing_agent/main.py b/packages/langchain/examples/user_facing_agent/main.py new file mode 100644 index 0000000..ff5a231 --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/main.py @@ -0,0 +1,122 @@ +"""A user-facing agent acting on behalf of the caller: a calendar assistant. + +The caller's Keycard token is exchanged per tool call for a Google Calendar +token (RFC 8693), so every calendar read is attributed to agent-for-user in +the zone's audit log. When the user has not granted calendar access yet, the +run pauses with a LangGraph interrupt; this CLI prints the consent link, +waits, then resumes the same run. + +Run: uv run main.py "what's on my calendar today?" +""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timedelta, timezone + +import httpx +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_anthropic import ChatAnthropic +from langchain_core.messages import HumanMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +CALENDAR = os.environ.get( + "KEYCARD_CALENDAR_RESOURCE", "https://www.googleapis.com/calendar/v3" +) + +_http = httpx.Client(timeout=httpx.Timeout(15.0, connect=5.0)) + + +@tool +def list_events(days_ahead: int = 0) -> str: + """List the user's calendar events for one day, days_ahead days from today.""" + access = get_access_context() + if access.has_error(): + return f"Calendar unavailable: {access.get_error()['message']}" + if access.has_resource_error(CALENDAR): + return f"Calendar access not granted: {access.get_resource_error(CALENDAR)}" + + day = datetime.now(timezone.utc) + timedelta(days=days_ahead) + start = day.replace(hour=0, minute=0, second=0, microsecond=0) + response = _http.get( + f"{CALENDAR}/calendars/primary/events", + params={ + "timeMin": start.isoformat(), + "timeMax": (start + timedelta(days=1)).isoformat(), + "singleEvents": "true", + "orderBy": "startTime", + }, + headers={"Authorization": f"Bearer {access.access(CALENDAR).access_token}"}, + ) + if response.status_code != 200: + return f"Calendar API error {response.status_code}: {response.text[:200]}" + events = response.json().get("items", []) + if not events: + return "No events that day." + return "\n".join( + f"- {e.get('start', {}).get('dateTime', e.get('start', {}).get('date'))}: " + f"{e.get('summary', '(no title)')}" + for e in events + ) + + +def main() -> None: + question = " ".join(sys.argv[1:]) or "What's on my calendar today?" + identity = KeycardIdentity(subject_token=os.environ["KEYCARD_SUBJECT_TOKEN"]) + + keycard = KeycardGrantMiddleware( + zone_url=os.environ["KEYCARD_ZONE_URL"], + resources=[CALENDAR], + client_id=os.environ["KEYCARD_CLIENT_ID"], + client_secret=os.environ["KEYCARD_CLIENT_SECRET"], + # A missing grant pauses the run instead of failing; the loop below + # prints the link and resumes after consent. + authorization_url=os.environ.get( + "KEYCARD_AUTHORIZATION_URL", os.environ["KEYCARD_ZONE_URL"] + ), + ) + agent = create_agent( + model=ChatAnthropic( + model=os.environ.get("ANTHROPIC_MODEL", "claude-opus-5"), + max_tokens=4096, + ), + tools=[list_events], + middleware=[keycard], + context_schema=KeycardIdentity, + checkpointer=InMemorySaver(), # interrupts require a checkpointer + ) + config = {"configurable": {"thread_id": "cli"}} + + result = agent.invoke( + {"messages": [HumanMessage(question)]}, config, context=identity + ) + while result.get("__interrupt__"): + payload = result["__interrupt__"][0].value + print(f"\n{payload['message']}") + print(f" {payload.get('authorization_url') or payload.get('sign_in_url')}") + input("\nPress Enter after granting access to resume... ") + # Runtime context is not checkpointed: a resume re-supplies identity. + result = agent.invoke(Command(resume="granted"), config, context=identity) + + final = result["messages"][-1] + content = final.content + if isinstance(content, list): + content = "\n".join( + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ).strip() + print(content) + + +if __name__ == "__main__": + main() diff --git a/packages/langchain/examples/user_facing_agent/pyproject.toml b/packages/langchain/examples/user_facing_agent/pyproject.toml new file mode 100644 index 0000000..f6d664d --- /dev/null +++ b/packages/langchain/examples/user_facing_agent/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "user-facing-agent-example" +version = "0.1.0" +description = "A user-facing LangChain agent acting on behalf of the caller, with consent pauses via LangGraph interrupts" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "keycardai-langchain", + "langchain-anthropic>=1.0", + "httpx>=0.27.0,<1.0.0", +] + +[tool.uv.sources] +keycardai-langchain = { path = "../../", editable = true } diff --git a/packages/langchain/pyproject.toml b/packages/langchain/pyproject.toml new file mode 100644 index 0000000..ad0509c --- /dev/null +++ b/packages/langchain/pyproject.toml @@ -0,0 +1,124 @@ +[project] +name = "keycardai-langchain" +dynamic = ["version"] +description = "LangChain integration for Keycard: delegated, per-tool-call access with brokered credentials" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Keycard", email = "support@keycard.ai" }] +dependencies = [ + "httpx>=0.27.2", + "keycardai-oauth>=0.9.0", + "langchain>=1.0", + "langgraph>=1.0", +] +keywords = ["langchain", "langgraph", "agents", "oauth", "token-exchange", "authentication", "keycard"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Security", + "Topic :: Internet :: WWW/HTTP :: Session", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "License :: OSI Approved :: MIT License", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", +] + +[project.urls] +Homepage = "https://github.com/keycardai/python-sdk" +Repository = "https://github.com/keycardai/python-sdk" +Documentation = "https://docs.keycardai.com" +Issues = "https://github.com/keycardai/python-sdk/issues" + +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + +[tool.uv-dynamic-versioning] +vcs = "git" +pattern = "(?P\\d+\\.\\d+\\.\\d+)-keycardai-langchain" +style = "pep440" + +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +publish-url = "https://test.pypi.org/legacy/" +explicit = true + +[tool.hatch.build.targets.wheel] +packages = ["src/keycardai"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, we'll handle case by case +] +isort = { combine-as-imports = true, known-first-party = ["keycardai"] } + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["T20"] + +[tool.mypy] +strict = true +disallow_incomplete_defs = false +disallow_untyped_defs = false +disallow_untyped_calls = false + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false + +[tool.coverage.run] +source = ["tests", "src/keycardai"] + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@abc.abstractmethod", + "raise NotImplementedError", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q" +asyncio_mode = "auto" + +[tool.commitizen] +name = "cz_customize" +version = "0.1.0" +tag_format = "${version}-keycardai-langchain" +ignored_tag_formats = ["${version}-*"] +update_changelog_on_bump = true +bump_message = "bump: keycardai-langchain $current_version → $new_version" +major_version_zero = true + +[tool.commitizen.customize] +changelog_pattern = "^(feat|fix|refactor|perf|test|build|ci|revert)\\(keycardai-langchain\\)(!)?:" diff --git a/packages/langchain/src/keycardai/langchain/__init__.py b/packages/langchain/src/keycardai/langchain/__init__.py new file mode 100644 index 0000000..f174a1c --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/__init__.py @@ -0,0 +1,67 @@ +"""Keycard integration for LangChain agents. + +Adds delegated access at the tool-call boundary: every tool call gets a +short-lived credential brokered by Keycard, scoped to the identity the agent is +acting for, and audited as a delegation chain. + +Quick start: + + from langchain.agents import create_agent + from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, + ) + + keycard = KeycardGrantMiddleware( + zone_url="https://your-zone.keycard.cloud", + resources=["https://api.example.com"], + client_id="your-app", + client_secret=..., + ) + + @tool + def call_api(query: str) -> str: + \"\"\"Call the external API.\"\"\" + token = get_access_context().access("https://api.example.com").access_token + ... + + agent = create_agent( + model, + tools=[call_api], + middleware=[keycard], + context_schema=KeycardIdentity, + ) + + agent.invoke( + {"messages": [...]}, + context=KeycardIdentity(subject_token=caller_token), + ) + +Re-export guide: + +- Local definitions: ``KeycardGrantMiddleware``, ``KeycardIdentity``, + ``get_access_context``. +- Borrowed from ``keycardai-oauth``: ``AccessContext`` (the per-request token + container) and ``ResourceAccessError`` (raised only by + ``AccessContext.access``), re-exported so callers need one import. +""" + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.exceptions import ResourceAccessError + +from .middleware import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) + +__all__ = [ + # === Primary API === + "KeycardGrantMiddleware", + "KeycardIdentity", + "get_access_context", + # === Re-exported from keycardai-oauth === + "AccessContext", + "ResourceAccessError", +] diff --git a/packages/langchain/src/keycardai/langchain/middleware.py b/packages/langchain/src/keycardai/langchain/middleware.py new file mode 100644 index 0000000..7921814 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/middleware.py @@ -0,0 +1,351 @@ +"""Keycard grant middleware for LangChain 1.x agents. + +Grants delegated access at the tool-call boundary: before each tool executes, +the middleware exchanges the caller's identity for short-lived resource tokens +(RFC 8693) via the shared keycardai-oauth orchestration, and exposes the result +to the tool as a non-throwing AccessContext. + +The same middleware instance works under `create_agent` and `create_deep_agent` +(deepagents is built on the create_agent middleware system). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any +from weakref import WeakKeyDictionary + +from langchain_core.messages import ToolMessage +from langgraph.types import Command, interrupt + +from keycardai.oauth import AsyncClient, BasicAuth, ClientConfig, NoneAuth +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.server.token_exchange import exchange_tokens_for_resources +from langchain.agents.middleware import AgentMiddleware +from langchain.agents.middleware.types import ToolCallRequest + + +@dataclass +class KeycardIdentity: + """Per-invocation identity, passed as the agent's runtime context. + + Exactly one of the three should be set: + - subject_token: on-behalf-of. The caller's Keycard access token, exchanged + per tool call for resource tokens (RFC 8693). + - user_identifier: impersonation. A substitute-user exchange for this user, + authenticated by the agent's own application credential. Forbidden by + default; requires an explicit policy in the zone. + - as_self=True: the agent acts as itself (client credentials). No user + anywhere: resource access is attributed to the application alone. This is + deliberately explicit; a run with no identity at all stays an error (or a + sign-in interrupt), never silently escalates to the agent's own authority. + """ + + subject_token: str | None = None + user_identifier: str | None = None + as_self: bool = False + + def __bool__(self) -> bool: + return bool(self.subject_token or self.user_identifier or self.as_self) + + +_current_access: ContextVar[AccessContext | None] = ContextVar( + "keycard_access_context", default=None +) + + +def get_access_context() -> AccessContext: + """The AccessContext for the tool call currently executing. + + Call from inside a tool. Raises RuntimeError when no KeycardGrantMiddleware + wrapped this call. + """ + access = _current_access.get() + if access is None: + raise RuntimeError( + "No Keycard AccessContext for this tool call. Add KeycardGrantMiddleware " + "to the agent's middleware list and invoke the agent with a " + "KeycardIdentity context." + ) + return access + + +class KeycardGrantMiddleware(AgentMiddleware): + """Exchange the caller's identity for resource tokens on every tool call. + + Args: + zone_url: Keycard zone URL (issuer). Required unless `client` is given. + resources: Resource URLs to grant for every tool call. + client_id / client_secret: The agent's application credential. Used to + authenticate the exchange; required for impersonation. + tool_resources: Optional per-tool override, tool name -> resource URLs. + Tools absent from the map get `resources`. + request_scopes: Optional outbound scopes for the exchange, same shapes + as the core orchestrator (str | list | dict per resource). + authorization_url: When set, a failed exchange pauses the run with a + LangGraph interrupt instead of recording a silent error. The + interrupt payload carries this URL (str, or callable taking the + failed resource URLs) for the user to establish the grant; on + resume the exchange is retried. Requires a checkpointer. + sign_in_url: When set, a run that carries no identity at all pauses + with a `sign_in_required` interrupt linking here, instead of + failing. The whole flow then lives in the chat: sign in, resume. + fallback_identity: Identity used when the runtime context carries + none. Pass a callable to resolve it per tool call, so a sign-in + that happens mid-run is picked up on resume without a restart. + client: Injectable AsyncClient (tests). When set, zone_url is unused + and the client is reused as-is. + """ + + def __init__( + self, + *, + zone_url: str | None = None, + resources: list[str], + client_id: str | None = None, + client_secret: str | None = None, + tool_resources: dict[str, list[str]] | None = None, + request_scopes: str | list[str] | dict[str, str | list[str]] | None = None, + authorization_url: str | Callable[[list[str]], str] | None = None, + sign_in_url: str | None = None, + fallback_identity: KeycardIdentity + | Callable[[], KeycardIdentity | None] + | None = None, + client: AsyncClient | None = None, + ) -> None: + super().__init__() + if client is None and not zone_url: + raise ValueError( + "KeycardGrantMiddleware requires zone_url (or an injected client)" + ) + self._zone_url = zone_url + self._resources = list(resources) + self._client_id = client_id + self._client_secret = client_secret + self._tool_resources = tool_resources or {} + self._request_scopes = request_scopes + self._authorization_url = authorization_url + self._sign_in_url = sign_in_url + self._fallback_identity = fallback_identity + self._injected_client = client + self._loop_clients: WeakKeyDictionary[ + asyncio.AbstractEventLoop, AsyncClient + ] = WeakKeyDictionary() + + def _resolve_fallback(self) -> KeycardIdentity | None: + fallback = self._fallback_identity + return fallback() if callable(fallback) else fallback + + def _new_client(self) -> AsyncClient: + auth = ( + BasicAuth(self._client_id, self._client_secret) + if self._client_id and self._client_secret + else NoneAuth() + ) + return AsyncClient( + issuer=self._zone_url, + auth=auth, + config=ClientConfig( + enable_metadata_discovery=True, + auto_register_client=False, + ), + ) + + def _client(self) -> AsyncClient: + """The client bound to the running event loop. + + Cached per loop rather than per call: an AsyncClient holds connections + owned by its loop and must not outlive it. Under an async server the + loop persists, so this reuses one client (and one metadata discovery) + for the process. Sync callers that reach here through `asyncio.run` + get a fresh loop each time and therefore a fresh client. + """ + if self._injected_client is not None: + return self._injected_client + loop = asyncio.get_running_loop() + client = self._loop_clients.get(loop) + if client is None: + client = self._new_client() + self._loop_clients[loop] = client + return client + + def _resources_for(self, request: ToolCallRequest) -> list[str]: + name = request.tool_call.get("name", "") + return self._tool_resources.get(name, self._resources) + + def _resolve_identity(self, request: ToolCallRequest) -> KeycardIdentity | None: + """The effective identity for this tool call: context first, then fallback. + + Resolved per call: a sign-in that happens mid-run (via the + sign_in_required interrupt) is picked up on resume. + """ + identity = getattr(request.runtime, "context", None) + if identity is not None and ( + getattr(identity, "subject_token", None) + or getattr(identity, "user_identifier", None) + or getattr(identity, "as_self", False) + ): + return KeycardIdentity( + subject_token=getattr(identity, "subject_token", None), + user_identifier=getattr(identity, "user_identifier", None), + as_self=getattr(identity, "as_self", False), + ) + return self._resolve_fallback() + + def _scope_for(self, resource: str) -> str | None: + scopes = self._request_scopes + if scopes is None: + return None + value = scopes.get(resource) if isinstance(scopes, dict) else scopes + if value is None: + return None + return " ".join(value) if isinstance(value, list) else value + + async def _grant_as_self( + self, resources: list[str], access: AccessContext + ) -> AccessContext: + """Client-credentials acquisition: the agent's own authority, no subject. + + Not routed through exchange_tokens_for_resources(), which only models + subject-token flows. + """ + client = self._client() + for resource in resources: + try: + kwargs: dict[str, Any] = {"resource": resource} + scope = self._scope_for(resource) + if scope: + kwargs["scope"] = scope + token = await client.client_credentials_grant(**kwargs) + access.set_token(resource, token) + except Exception as e: + error: dict[str, str] = { + "message": f"Client credentials grant failed for {resource}" + } + if hasattr(e, "error"): + error["code"] = e.error + if getattr(e, "error_description", None): + error["description"] = e.error_description + if "code" not in error: + error["raw_error"] = str(e) + access.set_resource_error(resource, error) + return access + + async def _build_access(self, request: ToolCallRequest) -> AccessContext: + access = AccessContext() + identity = self._resolve_identity(request) + + if identity is None: + access.set_error( + { + "message": ( + "No Keycard identity for this run. Sign in to continue." + if self._sign_in_url + else "No Keycard identity on the runtime context. Invoke the " + "agent with context=KeycardIdentity(subject_token=...), " + "KeycardIdentity(user_identifier=...), or " + "KeycardIdentity(as_self=True)." + ), + "code": "missing_identity", + } + ) + return access + + if identity.as_self: + return await self._grant_as_self(self._resources_for(request), access) + + return await exchange_tokens_for_resources( + client=self._client(), + resources=self._resources_for(request), + subject_token=identity.subject_token or "", + access_context=access, + user_identifier=identity.user_identifier, + request_scopes=self._request_scopes, + ) + + _MAX_AUTHORIZATION_ATTEMPTS = 3 + + def _interrupt_payload( + self, failed: list[str], access: AccessContext + ) -> dict[str, Any]: + url = self._authorization_url + if callable(url): + url = url(failed) + return { + "type": "authorization_required", + "resources": failed, + "authorization_url": url, + "errors": {r: access.get_resource_error(r) for r in failed}, + "message": ( + "Access to the resources above has not been granted yet. " + "Open the authorization URL to grant it, then resume the run." + ), + } + + def _sign_in_payload(self) -> dict[str, Any]: + return { + "type": "sign_in_required", + "sign_in_url": self._sign_in_url, + "message": ( + "Sign in with Keycard to continue. Open the link, sign in, " + "then resume the run." + ), + } + + def _pending_interrupt( + self, access: AccessContext, request: ToolCallRequest + ) -> dict[str, Any] | None: + """The interrupt this AccessContext calls for, if any. + + As-itself runs never interrupt: there is no user to send to a sign-in + or consent page, so failures stay on the AccessContext as errors for + the tool (and the operator's logs) to surface. + """ + identity = self._resolve_identity(request) + if identity is not None and identity.as_self: + return None + if access.has_error() and self._sign_in_url: + return self._sign_in_payload() + failed = access.get_failed_resources() + if failed and self._authorization_url is not None: + return self._interrupt_payload(failed, access) + return None + + async def awrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]], + ) -> ToolMessage | Command[Any]: + access = await self._build_access(request) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = await self._build_access(request) + token = _current_access.set(access) + try: + return await handler(request) + finally: + _current_access.reset(token) + + def wrap_tool_call( + self, + request: ToolCallRequest, + handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]], + ) -> ToolMessage | Command[Any]: + access = asyncio.run(self._build_access(request)) + for _ in range(self._MAX_AUTHORIZATION_ATTEMPTS): + payload = self._pending_interrupt(access, request) + if payload is None: + break + interrupt(payload) + access = asyncio.run(self._build_access(request)) + token = _current_access.set(access) + try: + return handler(request) + finally: + _current_access.reset(token) diff --git a/packages/langchain/src/keycardai/langchain/testing/__init__.py b/packages/langchain/src/keycardai/langchain/testing/__init__.py new file mode 100644 index 0000000..5219688 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/__init__.py @@ -0,0 +1,13 @@ +"""Test seams for agents built with keycardai-langchain. + +Lets tests exercise tools without a zone, a network, or real token exchange. + + from keycardai.langchain.testing import mock_access_context + + with mock_access_context(resource_tokens={"https://api.example.com": "tok"}): + result = my_tool.invoke({"query": "hello"}) +""" + +from .test_utils import mock_access_context, override_access_context + +__all__ = ["mock_access_context", "override_access_context"] diff --git a/packages/langchain/src/keycardai/langchain/testing/test_utils.py b/packages/langchain/src/keycardai/langchain/testing/test_utils.py new file mode 100644 index 0000000..3d0c5f9 --- /dev/null +++ b/packages/langchain/src/keycardai/langchain/testing/test_utils.py @@ -0,0 +1,84 @@ +"""Install a preloaded AccessContext for the duration of a test.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager + +from keycardai.oauth.server.access_context import AccessContext +from keycardai.oauth.types.models import TokenResponse + +from ..middleware import _current_access + + +@contextmanager +def override_access_context(access_context: AccessContext) -> Iterator[AccessContext]: + """Serve `access_context` to tools for the duration of the block. + + The full-control seam: build the AccessContext yourself (including partial + failures) and hand it over. `mock_access_context` covers the common cases. + """ + token = _current_access.set(access_context) + try: + yield access_context + finally: + _current_access.reset(token) + + +@contextmanager +def mock_access_context( + access_token: str | None = None, + resource_tokens: dict[str, str] | None = None, + resource_errors: dict[str, str] | None = None, + error_message: str | None = None, +) -> Iterator[AccessContext]: + """Serve a synthetic AccessContext to tools, with no exchange performed. + + Args: + access_token: Served for every resource. Convenient, but it cannot + catch a mistyped resource URL in an `access(...)` call, since every + lookup succeeds. Prefer `resource_tokens` when the test should + assert which resource a tool reads. + resource_tokens: Per-resource tokens, keyed by resource URL. + resource_errors: Per-resource failures, keyed by resource URL, as a + grant failure would record them. + error_message: A global failure (no identity, unreachable zone). Takes + precedence: no resource tokens are served. + """ + context = _AnyResourceAccessContext(access_token) + + if error_message is not None: + context.set_error({"message": error_message, "code": "mock_error"}) + else: + for resource, token in (resource_tokens or {}).items(): + context.set_token( + resource, TokenResponse(access_token=token, token_type="Bearer") + ) + for resource, message in (resource_errors or {}).items(): + context.set_resource_error( + resource, {"message": message, "code": "mock_resource_error"} + ) + + with override_access_context(context): + yield context + + +class _AnyResourceAccessContext(AccessContext): + """AccessContext that can serve one token for any resource. + + Only used when `mock_access_context(access_token=...)` is given; with + `resource_tokens` the base class behavior applies unchanged. + """ + + def __init__(self, default_token: str | None = None) -> None: + super().__init__() + self._default_token = default_token + + def access(self, resource: str) -> TokenResponse: + if ( + self._default_token is not None + and not self.has_errors() + and resource not in self.get_successful_resources() + ): + return TokenResponse(access_token=self._default_token, token_type="Bearer") + return super().access(resource) diff --git a/packages/langchain/tests/test_middleware.py b/packages/langchain/tests/test_middleware.py new file mode 100644 index 0000000..143991e --- /dev/null +++ b/packages/langchain/tests/test_middleware.py @@ -0,0 +1,266 @@ +"""Middleware behavior, exercised through a real create_agent loop. + +The exchange client is a stub, so no zone or network is involved; everything +else (the agent graph, the middleware hooks, the tool call) is real. +""" + +from __future__ import annotations + +import pytest +from langchain.agents import create_agent +from langchain.tools import tool +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.types import Command + +from keycardai.langchain import ( + KeycardGrantMiddleware, + KeycardIdentity, + get_access_context, +) +from keycardai.oauth import TokenResponse +from keycardai.oauth.types.models import TokenExchangeRequest + +RESOURCE = "https://api.example.test" +PROMPT = {"messages": [HumanMessage("Read the delegated token.")]} + + +class StubExchangeClient: + """Stands in for keycardai.oauth.AsyncClient on the exchange paths.""" + + def __init__(self) -> None: + self.exchange_calls: list[TokenExchangeRequest] = [] + self.impersonate_calls: list[dict[str, str]] = [] + self.self_calls: list[dict[str, str]] = [] + self.granted = True + self.self_granted = True + + async def exchange_token(self, request: TokenExchangeRequest) -> TokenResponse: + self.exchange_calls.append(request) + if not self.granted: + raise RuntimeError("no grant for this resource yet") + return TokenResponse( + access_token=f"obo-token-for-{request.resource}", + token_type="Bearer", + expires_in=300, + ) + + async def impersonate( + self, *, user_identifier: str, resource: str, scope: str | None = None + ) -> TokenResponse: + self.impersonate_calls.append({"user": user_identifier, "resource": resource}) + return TokenResponse( + access_token=f"impersonated-{user_identifier}-for-{resource}", + token_type="Bearer", + ) + + async def client_credentials_grant(self, request=None, **kwargs) -> TokenResponse: + self.self_calls.append(kwargs) + if not self.self_granted: + raise RuntimeError("policy denies this application self access") + return TokenResponse( + access_token=f"self-token-for-{kwargs.get('resource')}", + token_type="Bearer", + ) + + +@tool +def read_delegated_token(resource: str) -> str: + """Read the delegated Keycard token for a resource.""" + access = get_access_context() + if access.has_error(): + return f"GLOBAL_ERROR: {access.get_error()}" + if access.has_resource_error(resource): + return f"RESOURCE_ERROR: {access.get_resource_error(resource)}" + return f"TOKEN: {access.access(resource).access_token}" + + +class _ToolBindableFakeModel(GenericFakeChatModel): + """GenericFakeChatModel that tolerates bind_tools; the script drives calls.""" + + def bind_tools(self, tools, **kwargs): # noqa: ANN001, ANN003, ANN201 + return self + + +def scripted_model() -> GenericFakeChatModel: + return _ToolBindableFakeModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "read_delegated_token", + "args": {"resource": RESOURCE}, + "id": "call_1", + "type": "tool_call", + } + ], + ), + AIMessage(content="done"), + ] + ) + ) + + +def build_agent(stub: StubExchangeClient, **middleware_kwargs) -> object: + middleware = KeycardGrantMiddleware( + resources=[RESOURCE], client=stub, **middleware_kwargs + ) + checkpointer = ( + InMemorySaver() + if middleware_kwargs.get("authorization_url") + or middleware_kwargs.get("sign_in_url") + else None + ) + return create_agent( + model=scripted_model(), + tools=[read_delegated_token], + middleware=[middleware], + context_schema=KeycardIdentity, + checkpointer=checkpointer, + ) + + +def last_tool_message(result: dict) -> ToolMessage: + messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert messages, f"no ToolMessage in {result['messages']}" + return messages[-1] + + +def test_on_behalf_of_exchanges_the_callers_token() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + assert stub.exchange_calls[0].subject_token == "caller-token" + + +async def test_on_behalf_of_works_on_the_async_path() -> None: + stub = StubExchangeClient() + result = await build_agent(stub).ainvoke( + PROMPT, context=KeycardIdentity(subject_token="caller-token") + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_impersonation_uses_the_substitute_user_path() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke( + PROMPT, context=KeycardIdentity(user_identifier="user@example.com") + ) + assert "TOKEN: impersonated-user@example.com" in last_tool_message(result).content + assert not stub.exchange_calls + assert len(stub.impersonate_calls) == 1 + + +def test_missing_identity_is_recorded_not_raised() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT) + content = last_tool_message(result).content + assert "GLOBAL_ERROR" in content + assert "missing_identity" in content + assert not stub.exchange_calls + + +def test_tool_schema_carries_no_keycard_plumbing() -> None: + """The model must not see (or be able to supply) auth arguments.""" + properties = read_delegated_token.args_schema.model_json_schema()["properties"] + assert set(properties) == {"resource"} + + +def test_authorization_interrupt_pauses_then_resumes() -> None: + stub = StubExchangeClient() + stub.granted = False + agent = build_agent(stub, authorization_url="https://consent.example/authorize") + config = {"configurable": {"thread_id": "auth-interrupt"}} + + result = agent.invoke( + PROMPT, config, context=KeycardIdentity(subject_token="caller-token") + ) + interrupts = result.get("__interrupt__", []) + assert len(interrupts) == 1 + payload = interrupts[0].value + assert payload["type"] == "authorization_required" + assert payload["authorization_url"] == "https://consent.example/authorize" + assert payload["resources"] == [RESOURCE] + + stub.granted = True # the user consented out of band + # Runtime context is not checkpointed, so a resume re-supplies identity, + # exactly as a server does on every run. + result = agent.invoke( + Command(resume="authorized"), + config, + context=KeycardIdentity(subject_token="caller-token"), + ) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_sign_in_interrupt_picks_up_identity_without_a_restart() -> None: + stub = StubExchangeClient() + signed_in: dict[str, KeycardIdentity | None] = {"identity": None} + agent = build_agent( + stub, + sign_in_url="https://consent.example/", + authorization_url="https://consent.example/authorize", + fallback_identity=lambda: signed_in["identity"], + ) + config = {"configurable": {"thread_id": "sign-in-interrupt"}} + + result = agent.invoke(PROMPT, config) + payload = result["__interrupt__"][0].value + assert payload["type"] == "sign_in_required" + assert payload["sign_in_url"] == "https://consent.example/" + assert not stub.exchange_calls + + signed_in["identity"] = KeycardIdentity(subject_token="caller-token") + result = agent.invoke(Command(resume="signed in"), config) + assert f"TOKEN: obo-token-for-{RESOURCE}" in last_tool_message(result).content + + +def test_request_scopes_reach_the_exchange() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["read", "write"]}) + agent.invoke(PROMPT, context=KeycardIdentity(subject_token="caller-token")) + assert stub.exchange_calls[0].scope == "read write" + + +def test_as_self_uses_client_credentials_not_exchange() -> None: + stub = StubExchangeClient() + result = build_agent(stub).invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert f"TOKEN: self-token-for-{RESOURCE}" in last_tool_message(result).content + assert not stub.exchange_calls + assert not stub.impersonate_calls + assert stub.self_calls == [{"resource": RESOURCE}] + + +def test_as_self_request_scopes_reach_the_grant() -> None: + stub = StubExchangeClient() + agent = build_agent(stub, request_scopes={RESOURCE: ["repo:read"]}) + agent.invoke(PROMPT, context=KeycardIdentity(as_self=True)) + assert stub.self_calls == [{"resource": RESOURCE, "scope": "repo:read"}] + + +def test_as_self_denial_is_an_error_never_an_interrupt() -> None: + """No user exists to send to a consent page, so as-itself must not pause.""" + stub = StubExchangeClient() + stub.self_granted = False + agent = build_agent( + stub, + authorization_url="https://consent.example/authorize", + sign_in_url="https://consent.example/", + ) + config = {"configurable": {"thread_id": "as-self-denied"}} + + result = agent.invoke(PROMPT, config, context=KeycardIdentity(as_self=True)) + assert not result.get("__interrupt__") + content = last_tool_message(result).content + assert "RESOURCE_ERROR" in content + assert "Client credentials grant failed" in content + + +def test_zone_url_is_required_without_an_injected_client() -> None: + with pytest.raises(ValueError, match="zone_url"): + KeycardGrantMiddleware(resources=[RESOURCE]) diff --git a/packages/langchain/tests/test_testing_seam.py b/packages/langchain/tests/test_testing_seam.py new file mode 100644 index 0000000..d26911a --- /dev/null +++ b/packages/langchain/tests/test_testing_seam.py @@ -0,0 +1,49 @@ +"""The testing seam: exercise tools with no middleware, zone, or network.""" + +from __future__ import annotations + +import pytest +from langchain.tools import tool + +from keycardai.langchain import ResourceAccessError, get_access_context +from keycardai.langchain.testing import mock_access_context + +RESOURCE = "https://api.example.test" + + +@tool +def call_api() -> str: + """Call the API with the delegated token.""" + access = get_access_context() + if access.has_error(): + return f"unavailable: {access.get_error()['message']}" + return access.access(RESOURCE).access_token + + +def test_resource_tokens_are_served_per_resource() -> None: + with mock_access_context(resource_tokens={RESOURCE: "tok-123"}): + assert call_api.invoke({}) == "tok-123" + + +def test_any_resource_token_is_a_convenience_with_a_tradeoff() -> None: + """The bare form serves any resource, so it cannot catch a wrong URL.""" + with mock_access_context(access_token="tok-any"): + assert call_api.invoke({}) == "tok-any" + + +def test_global_error_is_visible_to_the_tool() -> None: + with mock_access_context(error_message="no identity for this run"): + assert call_api.invoke({}) == "unavailable: no identity for this run" + + +def test_resource_error_raises_only_on_access() -> None: + with mock_access_context(resource_errors={RESOURCE: "not granted"}) as access: + assert access.has_errors() + assert not access.has_error() # per-resource, not global + with pytest.raises(ResourceAccessError): + access.access(RESOURCE) + + +def test_outside_the_seam_the_tool_reports_a_missing_middleware() -> None: + with pytest.raises(RuntimeError, match="KeycardGrantMiddleware"): + call_api.invoke({}) diff --git a/pyproject.toml b/pyproject.toml index f5c94e9..2100b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ members = [ "packages/starlette", "packages/mcp", "packages/a2a", + "packages/langchain", ] # Examples are standalone projects with their own lockfiles: each pins its parent # package via a path source and resolves the rest from the index. diff --git a/uv.lock b/uv.lock index 096c477..81df9e9 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ resolution-markers = [ members = [ "keycardai", "keycardai-a2a", + "keycardai-langchain", "keycardai-mcp", "keycardai-oauth", "keycardai-starlette", @@ -1455,6 +1456,33 @@ requires-dist = [ ] provides-extras = ["dev", "test"] +[[package]] +name = "keycardai-langchain" +source = { editable = "packages/langchain" } +dependencies = [ + { name = "httpx" }, + { name = "keycardai-oauth" }, + { name = "langchain" }, + { name = "langgraph" }, +] + +[package.optional-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "keycardai-oauth", editable = "packages/oauth" }, + { name = "langchain", specifier = ">=1.0" }, + { name = "langgraph", specifier = ">=1.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=1.1.0" }, +] +provides-extras = ["test"] + [[package]] name = "keycardai-mcp" source = { editable = "packages/mcp" }